Merge branch 'main' of http://192.168.21.200:8418/4v5u09Z5a4Yuc/metin2-client
# Conflicts: # docs/RENDERING-REPAIR-PLAN.md
This commit is contained in:
@@ -5,6 +5,8 @@
|
|||||||
|
|
||||||
# build
|
# build
|
||||||
/build/
|
/build/
|
||||||
|
# 渲染测试的落图输出(test/rendering 用例自建)
|
||||||
|
/project/build/
|
||||||
/build-ios/
|
/build-ios/
|
||||||
/build-android/
|
/build-android/
|
||||||
/build-classic/
|
/build-classic/
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<!-- generated-by: gsd-doc-writer -->
|
<!-- generated-by: gsd-doc-writer -->
|
||||||
# Android 真机测试指南
|
# Android 真机测试指南
|
||||||
|
|
||||||
本文说明如何在 Android 真机上安装 `mtgodot-poc`、生成并导入资源包、验证资源挂载,以及进行 40250 客户端测试。
|
本文说明如何在 Android 真机上安装本客户端(APK 产物名仍是 `mtgodot-poc.apk`,包名 `org.internal.mtgodotpoc`)、生成并导入资源包、验证资源挂载,以及进行 40250 客户端测试。
|
||||||
|
|
||||||
## 1. 当前测试范围与已知前置条件
|
## 1. 当前测试范围与已知前置条件
|
||||||
|
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
当前仓库的资源部署存在一个需要特别注意的限制:
|
当前仓库的资源部署存在一个需要特别注意的限制:
|
||||||
|
|
||||||
- `export-android.sh --install` 会把资源推到 `/sdcard/Android/data/org.internal.mtgodotpoc/files/assets.zip`。
|
- `export-android.sh --install` 会把资源推到 `/sdcard/Android/data/org.internal.mtgodotpoc/files/assets.zip`。
|
||||||
- `project/asset_pack.gd` 首先查找 `user://assets.zip`,当前没有显式把上述外部目录加入候选路径。
|
- `project/asset_pack.gd` 的候选顺序是 `MT_ASSETS_ZIP` 环境变量 → `user://assets.zip` → `OS.get_user_data_dir()/assets.zip` → 可执行文件目录 → `res://../assets.zip`(见 `_find_local_zip()`);上述外部目录不在候选路径里。
|
||||||
- 因此,Debug 真机测试应把外部目录中的压缩包再复制到应用私有 `files/` 目录,确保客户端的 `user://assets.zip` 能命中。
|
- 因此,Debug 真机测试应把外部目录中的压缩包再复制到应用私有 `files/` 目录,确保客户端的 `user://assets.zip` 能命中。
|
||||||
|
|
||||||
另外,40250 classic 网络栈当前由进程环境变量 `MT_PROTOCOL=classic` 选择。Android 通过 Activity 启动 APK 时不能直接继承 Mac 终端的环境变量;如果 APK 尚未增加 Android 侧的协议配置入口或将 classic 设为移动端默认值,则只能完成 APK、资源和 UI 验证,不能据此宣称已经完成真实 40250 登录验收。
|
另外,40250 classic 网络栈当前由进程环境变量 `MT_PROTOCOL=classic` 选择。Android 通过 Activity 启动 APK 时不能直接继承 Mac 终端的环境变量;如果 APK 尚未增加 Android 侧的协议配置入口或将 classic 设为移动端默认值,则只能完成 APK、资源和 UI 验证,不能据此宣称已经完成真实 40250 登录验收。
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
进入仓库并设置环境变量。路径按本机安装位置调整:
|
进入仓库并设置环境变量。路径按本机安装位置调整:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /Users/shenlei/Work/mt/mtgodot-poc
|
cd /Users/shenlei/Work/mt/metin2-client
|
||||||
|
|
||||||
export ANDROID_SDK_ROOT="$HOME/Library/Android/sdk"
|
export ANDROID_SDK_ROOT="$HOME/Library/Android/sdk"
|
||||||
export ANDROID_HOME="$ANDROID_SDK_ROOT"
|
export ANDROID_HOME="$ANDROID_SDK_ROOT"
|
||||||
@@ -369,7 +369,7 @@ shasum -a 256 build/export/mtgodot-poc.apk \
|
|||||||
首次测试按以下顺序执行:
|
首次测试按以下顺序执行:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /Users/shenlei/Work/mt/mtgodot-poc
|
cd /Users/shenlei/Work/mt/metin2-client
|
||||||
|
|
||||||
./pack-assets.sh
|
./pack-assets.sh
|
||||||
./export-android.sh Debug
|
./export-android.sh Debug
|
||||||
|
|||||||
+6
-6
@@ -1,4 +1,4 @@
|
|||||||
# mtgodot-poc — 未完成工作清单
|
# metin2-client — 未完成工作清单
|
||||||
|
|
||||||
> 更新:2026-08-29 · 配套 [`GODOT-POC-PLAN.md`](./GODOT-POC-PLAN.md)(计划)、[`MIDREVIEW.md`](./MIDREVIEW.md)(中期评审)、
|
> 更新: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)(距参考图的保真化差距)。
|
> [`SHINSOO-WORLD-RENDERING.md`](./SHINSOO-WORLD-RENDERING.md)(世界渲染实施)、[`PARITY-GAP.md`](./PARITY-GAP.md)(距参考图的保真化差距)。
|
||||||
@@ -55,8 +55,8 @@
|
|||||||
| B6 | 贴图采样设置(filter / wrap / mip / 各向异性) | ⬜ | S | 现在 shader 里写死 `filter_linear_mipmap, repeat_enable`。 |
|
| 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。 |
|
| 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 算)。 |
|
| 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 动画面不再漏蒙皮。 |
|
| 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 休眠。✅ 装备层已接:`project/ui/equip_model.gd:283-288` `_apply_body()` 读 `item_proto` 的 `specular` 列并 `set("specular_power", sp / 100.0)`。⬜ 只驱动身体/护甲一个部位(武器 / 发型 / 时装未接),且 power 未按受控参考帧标定(PARITY §0 门禁)。`SRC_ADD` / `SRC_ADD_SKIN` 已覆盖 additive 发光的一种,且 additive 动画面不再漏蒙皮。 |
|
||||||
| B10 | 背面剔除 / 双面 | 🟡 | S | 世界静态对象(W3/B1)已按 `two_sided` + 贴图名切 `CULL_DISABLED`。角色 `m2_material` shader 仍写死 `cull_back`(`MaterialDesc::two_sided` 死字段)。需给角色也按材质切。 |
|
| B10 | 背面剔除 / 双面 | ✅ 首版 | S | 世界静态对象(W3/B1)已按 `two_sided` + 贴图名切 `CULL_DISABLED`。✅ 角色也已按材质切:`m2_material.cpp:188-211` `shader_for()` 对 `two_sided` 把 `cull_back` replace 成 `cull_disabled`(opaque/blend × 普通/蒙皮 四个变体各自缓存),`metin2_model.cpp:1182`(身体)/ `:769`(武器)经 `decide_two_sided()` 驱动,`_load_hair()` 直接置 true。⬜ 判据仍是启发式 —— `gr2::MaterialInfo.two_sided` 按材质名(`2side`/`twoside`/`double`/`leaf`/`tree`/`ivy`/`fence`)+ 表面名兜底(`hair`/`cloak`/`cape`/`skirt`/`ribbon`/`leaf`),不是真 `ExtendedData` 的 "Two-sided"。 |
|
||||||
| B11 | winding 逐模型验 | ⬜ | S | `flip_winding` 默认 false(4 个骨架验过,未全量)。 |
|
| B11 | winding 逐模型验 | ⬜ | S | `flip_winding` 默认 false(4 个骨架验过,未全量)。 |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -117,14 +117,14 @@
|
|||||||
|
|
||||||
| ID | 项 | 状态 | 规模 | 说明 |
|
| 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),发布前须换。 |
|
| F0 | vendored 原生依赖 | ✅ | S | libsodium / libzstd / miniLZO 从 Homebrew 改为**源码内建**(`extension/third_party/`,pinned submodule + vendored miniLZO)—— 同一棵树可交叉编 NDK / iOS SDK。2026-09-02 起第四个:**Crypto++ 8.4.0**(vendored 源码,`MT_PROTOCOL=classic` 的 DH2/CTR cipher 用,公有领域许可)。别名 `mt3p::sodium` / `mt3p::zstd` / `mt3p::minilzo` / `mt3p::cryptopp`,无源码改动,macOS 9/9 CTest + 真实 item/mob_proto 解码逐字一致。详见 `docs/THIRD-PARTY.md`。miniLZO 是 GPL —— 自用可接受(同 oodle1.c),发布前须换。 |
|
||||||
| F1 | iOS(iPhone 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。预留 2–3 天。详见 `docs/PLATFORMS.md`。 |
|
| F1 | iOS(iPhone 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。预留 2–3 天。详见 `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 导出 preset(Gradle)+ 一加 13 真机跑。**只验 Vulkan**,不做 Compatibility(GLES3) / 老机。详见 `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 导出 preset(Gradle)+ 一加 13 真机跑。**只验 Vulkan**,不做 Compatibility(GLES3) / 老机。详见 `docs/PLATFORMS.md`。 |
|
||||||
| F3 | 三端一致性 | ⬜ | S | 同工程 / 同 t / 同机位截图,iPhone 16 · 一加 13 · Mac 对齐。 |
|
| 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)、移动端画质核对。 |
|
| 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 生命周期 | 🟡 | S–M | ✅ `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 smoke(suspend/resume/reconnect 绑定 + 幂等 + 信号)通过,ctest 9/9,iOS 编过。渲染上下文丢失由 Godot Vulkan 自恢复,GPU 资源全 RenderingServer 托管,无需处理。⬜ 剩真机验证后台 30s→掐连接→回前台重连、iOS 内存告警实际触发。 |
|
| F5 | App 生命周期 | 🟡 | S–M | ✅ `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 smoke(suspend/resume/reconnect 绑定 + 幂等 + 信号)通过,ctest 9/9,iOS 编过。渲染上下文丢失由 Godot Vulkan 自恢复,GPU 资源全 RenderingServer 托管,无需处理。⬜ 剩真机验证后台 30s→掐连接→回前台重连、iOS 内存告警实际触发。 |
|
||||||
| F6 | 打包 | ⬜ | S–M | macOS / iOS / Android export preset、资产打包策略、iOS 签名。自用,无商店上架。 |
|
| F6 | 打包 | ⬜ | S–M | 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`。 |
|
| F9 | 实体点选精度 | ✅ 首版 | 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` + 项目导入无错。 |
|
| 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。 |
|
| F8 | `godot-cpp` 钉稳定版 | ✅ | S | gitlink 精确锁定 `101ae38034304346a46ea9ea84ae156d3e860496`,`.gitmodules` 已移除移动的 `master` branch 配置;目标 API 固定为 Godot 4.7。 |
|
||||||
|
|
||||||
@@ -183,7 +183,7 @@ UI / Python 脚本层、多角色 AI、网络、玩法逻辑、eterpack(Phase
|
|||||||
|
|
||||||
### Sprint 1 —— 小项收尾(~1 周)
|
### Sprint 1 —— 小项收尾(~1 周)
|
||||||
|
|
||||||
已完成:`A3` · `A6` · `F8` · `I1`,并补完 `A9`/`A10` 正确性问题。
|
已完成:`A3` · `A6` · `F8`(`godot-cpp` 钉版)· `I1`,并补完 `A9`/`A10` 正确性问题。
|
||||||
剩余:`A5`(CPU 路径原地更新)· `A7`(前台 Metal stress 基线)· `A8`+`H1`(桥接层畸形用例与全语料 draw 冒烟)。
|
剩余:`A5`(CPU 路径原地更新)· `A7`(前台 Metal stress 基线)· `A8`+`H1`(桥接层畸形用例与全语料 draw 冒烟)。
|
||||||
|
|
||||||
### Sprint 2 —— 组合基础(~2 周)
|
### Sprint 2 —— 组合基础(~2 周)
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
> **[待读参考]** 并给出应读的参考文件,而不是臆造。
|
> **[待读参考]** 并给出应读的参考文件,而不是臆造。
|
||||||
>
|
>
|
||||||
> 路径约定:`REF/` = `/Users/shenlei/Work/mt/40250/ClientVS22/source/`,
|
> 路径约定:`REF/` = `/Users/shenlei/Work/mt/40250/ClientVS22/source/`,
|
||||||
> `POC/` = `/Users/shenlei/Work/mt/mtgodot-poc/`。
|
> `POC/` = `/Users/shenlei/Work/mt/metin2-client/`。
|
||||||
>
|
>
|
||||||
> 创建 / 修订日期:2026-09-04
|
> 创建 / 修订日期:2026-09-04
|
||||||
|
|
||||||
@@ -7880,7 +7880,7 @@ dx² + dy² > s_fLimitDistance → 不播
|
|||||||
project/inventory_ui_test.gd —— FakeProto.item() 补 sub_type=0 / anti_flags=0 /
|
project/inventory_ui_test.gd —— FakeProto.item() 补 sub_type=0 / anti_flags=0 /
|
||||||
wear_flags=1<<4 / limits=[] 让 iv.use(5)(vnum 19 武器)仍过新前置门
|
wear_flags=1<<4 / limits=[] 让 iv.use(5)(vnum 19 武器)仍过新前置门
|
||||||
|
|
||||||
测试:M2_ASSETS=/Users/shenlei/Work/mt/mtgodot-poc/assets ctest --test-dir build → 21/21
|
测试:M2_ASSETS=/Users/shenlei/Work/mt/metin2-client/assets ctest --test-dir build → 21/21
|
||||||
(无 C++ 改动);56 个 GDScript 回归全 PASS(新增 equip_rules_test;inventory_test /
|
(无 C++ 改动);56 个 GDScript 回归全 PASS(新增 equip_rules_test;inventory_test /
|
||||||
inventory_ui_test 保持绿)。
|
inventory_ui_test 保持绿)。
|
||||||
|
|
||||||
@@ -7961,7 +7961,7 @@ dx² + dy² > s_fLimitDistance → 不播
|
|||||||
weapon_gr2 / shield_gr2 / hair_gr2、脱时装武器重解析、婚纱(桩返回 201)遮武器保头发、
|
weapon_gr2 / shield_gr2 / hair_gr2、脱时装武器重解析、婚纱(桩返回 201)遮武器保头发、
|
||||||
婚纱换捧花(vnum 50201)_last_weapon_vnum 恢复为 50201
|
婚纱换捧花(vnum 50201)_last_weapon_vnum 恢复为 50201
|
||||||
|
|
||||||
测试:M2_ASSETS=/Users/shenlei/Work/mt/mtgodot-poc/assets ctest --test-dir build → 21/21
|
测试:M2_ASSETS=/Users/shenlei/Work/mt/metin2-client/assets ctest --test-dir build → 21/21
|
||||||
(无 C++ 改动);57 个 GDScript 回归全 PASS(新增 part_hiding_test;equip_model_test /
|
(无 C++ 改动);57 个 GDScript 回归全 PASS(新增 part_hiding_test;equip_model_test /
|
||||||
equip_rules_test / inventory_ui_test 保持绿)。
|
equip_rules_test / inventory_ui_test 保持绿)。
|
||||||
|
|
||||||
@@ -9083,7 +9083,7 @@ dx² + dy² > s_fLimitDistance → 不播
|
|||||||
🔎 真机目视:鼠标悬停某 NPC / 怪 → 其头顶名字立即强显(即便未开「总显名字」且超 3500 距)、
|
🔎 真机目视:鼠标悬停某 NPC / 怪 → 其头顶名字立即强显(即便未开「总显名字」且超 3500 距)、
|
||||||
移开 → 复原;鼠标压 UI / 悬停地面物品时不强显任何角色名。
|
移开 → 复原;鼠标压 UI / 悬停地面物品时不强显任何角色名。
|
||||||
参考(sole baseline = /Users/shenlei/Work/mt/40250/ClientVS22/source/;运行时 python =
|
参考(sole baseline = /Users/shenlei/Work/mt/40250/ClientVS22/source/;运行时 python =
|
||||||
mtgodot-poc/assets/root/game.py):
|
metin2-client/assets/root/game.py):
|
||||||
root/game.py:1519 GameWindow.OnRender()
|
root/game.py:1519 GameWindow.OnRender()
|
||||||
root/game.py:1531 textTail.UpdateAllTextTail()
|
root/game.py:1531 textTail.UpdateAllTextTail()
|
||||||
root/game.py:1533 if True == wndMgr.IsPickedWindow(self.hWnd) —— 鼠标在 3D game 窗口上才拾取
|
root/game.py:1533 if True == wndMgr.IsPickedWindow(self.hWnd) —— 鼠标在 3D game 窗口上才拾取
|
||||||
|
|||||||
+46
-46
@@ -1,4 +1,4 @@
|
|||||||
# 40250/ClientVS22 → mtgodot-poc 1:1 功能差距
|
# 40250/ClientVS22 → metin2-client 1:1 功能差距
|
||||||
|
|
||||||
> 对照范围:`../40250/ClientVS22/`(40250 客户端源码及其运行时模块)以及本工程当前实现。
|
> 对照范围:`../40250/ClientVS22/`(40250 客户端源码及其运行时模块)以及本工程当前实现。
|
||||||
> 当前工程默认运行 `MT_PROTOCOL=classic`;`extension/src/net/wire.h` 仅属于显式选择的 m2dev
|
> 当前工程默认运行 `MT_PROTOCOL=classic`;`extension/src/net/wire.h` 仅属于显式选择的 m2dev
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
> 渲染细节另见 `PARITY-GAP.md`,动画、材质和资产管线另见 `BACKLOG.md`,网络实现另见
|
> 渲染细节另见 `PARITY-GAP.md`,动画、材质和资产管线另见 `BACKLOG.md`,网络实现另见
|
||||||
> `CLIENT-PORT.md`。
|
> `CLIENT-PORT.md`。
|
||||||
>
|
>
|
||||||
> **本文末「ClientVS22 实现细节审计(2026-09-04)」每一条的具体修改细节见
|
> **本文末「ClientVS22 实现细节审计(2026-09-02)」每一条的具体修改细节见
|
||||||
> [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md)**(逐条给出 40250/ClientVS22 的参考
|
> [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md)**(逐条给出 40250/ClientVS22 的参考
|
||||||
> 源码位置、常量与算法,以及本工程要改的文件/函数;其附录 A 列出本节中
|
> 源码位置、常量与算法,以及本工程要改的文件/函数;其附录 A 列出本节中
|
||||||
> 需要订正的条目,分「参考行为不符 / 表述需加限定 / 低估了当前差距」三档)。
|
> 需要订正的条目,分「参考行为不符 / 表述需加限定 / 低估了当前差距」三档)。
|
||||||
@@ -1848,7 +1848,7 @@
|
|||||||
|
|
||||||
## 结论
|
## 结论
|
||||||
|
|
||||||
`mtgodot-poc` 已经不是早期“只能登录、移动和攻击”的骨架:登录到 PHASE_GAME、基础战斗、
|
`metin2-client` 已经不是早期“只能登录、移动和攻击”的骨架:登录到 PHASE_GAME、基础战斗、
|
||||||
背包 / 装备、技能 UI、任务、聊天、组队 / 好友、商店 / 交易 / 仓库、精炼,
|
背包 / 装备、技能 UI、任务、聊天、组队 / 好友、商店 / 交易 / 仓库、精炼,
|
||||||
地图和部分特效都已有可运行首版。
|
地图和部分特效都已有可运行首版。
|
||||||
|
|
||||||
@@ -2065,7 +2065,7 @@ enum,不存在 `namespace CG` / `namespace GC`。本文此前的“68 + 109 =
|
|||||||
|
|
||||||
### 审计口径
|
### 审计口径
|
||||||
|
|
||||||
本节以当前 `mtgodot-poc` 源码和 `40250/ClientVS22` 源码做静态对照,重点检查
|
本节以当前 `metin2-client` 源码和 `40250/ClientVS22` 源码做静态对照,重点检查
|
||||||
`UserInterface`、`GameLib`、`EffectLib`、`MilesLib`、`EterLib`、`EterGrnLib`、
|
`UserInterface`、`GameLib`、`EffectLib`、`MilesLib`、`EterLib`、`EterGrnLib`、
|
||||||
`SpeedTreeLib` 和 `PRTerrainLib` 中承担实际运行时语义的模块。
|
`SpeedTreeLib` 和 `PRTerrainLib` 中承担实际运行时语义的模块。
|
||||||
它不是简单的功能勾选表,而是把问题分成四类:
|
它不是简单的功能勾选表,而是把问题分成四类:
|
||||||
@@ -2091,14 +2091,14 @@ Python 脚本;文档中出现的 `assets/root/*.py` 均属于本工程迁移
|
|||||||
角色列表保存每槽 `lAddr/wPort` 后,`M2Client.enter_game(index)` 会断开第一条游戏连接,
|
角色列表保存每槽 `lAddr/wPort` 后,`M2Client.enter_game(index)` 会断开第一条游戏连接,
|
||||||
按角色槽地址重连,并在 DirectEnter 的 SELECT 阶段发送 `CG_CHARACTER_SELECT`。
|
按角色槽地址重连,并在 DirectEnter 的 SELECT 阶段发送 `CG_CHARACTER_SELECT`。
|
||||||
`MT_CLASSIC_ENTER_DELAY` 和真实两次 TCP 连接验收仍待完成。见
|
`MT_CLASSIC_ENTER_DELAY` 和真实两次 TCP 连接验收仍待完成。见
|
||||||
[`classic_session.cpp`](/Users/shenlei/Work/mt/mtgodot-poc/extension/src/net/classic/classic_session.cpp)、
|
[`classic_session.cpp`](/Users/shenlei/Work/mt/metin2-client/extension/src/net/classic/classic_session.cpp)、
|
||||||
[`m2_client.cpp`](/Users/shenlei/Work/mt/mtgodot-poc/extension/src/net/m2_client.cpp)。
|
[`m2_client.cpp`](/Users/shenlei/Work/mt/metin2-client/extension/src/net/m2_client.cpp)。
|
||||||
|
|
||||||
2. **游戏中断线的回登录链路已接入,真实 TCP 断线仍待验收。** 已建立游戏连接后的
|
2. **游戏中断线的回登录链路已接入,真实 TCP 断线仍待验收。** 已建立游戏连接后的
|
||||||
Classic 失败发 `disconnected`,`AppFlow` 无条件回 LOGIN;认证 / 游戏连接建立前的失败仍发
|
Classic 失败发 `disconnected`,`AppFlow` 无条件回 LOGIN;认证 / 游戏连接建立前的失败仍发
|
||||||
`login_failed`。`ReconnectUI` 已从 AppFlow 主流程移除,但独立文件仍保留作旧测试 / 可选增强。
|
`login_failed`。`ReconnectUI` 已从 AppFlow 主流程移除,但独立文件仍保留作旧测试 / 可选增强。
|
||||||
见 [`m2_client.cpp`](/Users/shenlei/Work/mt/mtgodot-poc/extension/src/net/m2_client.cpp)、
|
见 [`m2_client.cpp`](/Users/shenlei/Work/mt/metin2-client/extension/src/net/m2_client.cpp)、
|
||||||
[`app_flow.gd`](/Users/shenlei/Work/mt/mtgodot-poc/project/app_flow.gd)。
|
[`app_flow.gd`](/Users/shenlei/Work/mt/metin2-client/project/app_flow.gd)。
|
||||||
|
|
||||||
3. **跨服或换图的场景重建已完成首版,但还没有真实服务器验收。** Classic `GC_WARP` 已按
|
3. **跨服或换图的场景重建已完成首版,但还没有真实服务器验收。** Classic `GC_WARP` 已按
|
||||||
`lAddr:wPort` 重连;`world_reset` 会清理旧实体、掉落物、PVP / 决斗、目标和交互窗,
|
`lAddr:wPort` 重连;`world_reset` 会清理旧实体、掉落物、PVP / 决斗、目标和交互窗,
|
||||||
@@ -2108,11 +2108,11 @@ Python 脚本;文档中出现的 `assets/root/*.py` 均属于本工程迁移
|
|||||||
4. **选人和进入游戏吞掉底层发送失败(已修复第一批)。** `select_char()` / DirectEnter
|
4. **选人和进入游戏吞掉底层发送失败(已修复第一批)。** `select_char()` / DirectEnter
|
||||||
选人 / `enter_game()` 现在透传 `send_fixed()` 结果,`CG_ENTERGAME` 成功后执行 flush;
|
选人 / `enter_game()` 现在透传 `send_fixed()` 结果,`CG_ENTERGAME` 成功后执行 flush;
|
||||||
GDScript 入口优先调用 `enter_game(index)`。失败提示和真实发送失败注入仍待补充。
|
GDScript 入口优先调用 `enter_game(index)`。失败提示和真实发送失败注入仍待补充。
|
||||||
见 [`classic_session.cpp`](/Users/shenlei/Work/mt/mtgodot-poc/extension/src/net/classic/classic_session.cpp)。
|
见 [`classic_session.cpp`](/Users/shenlei/Work/mt/metin2-client/extension/src/net/classic/classic_session.cpp)。
|
||||||
|
|
||||||
5. **客户端版本包是硬编码的。** 当前固定发送 `metin2.bin` 和旧 timestamp,未读取实际
|
5. **客户端版本包是硬编码的。** 当前固定发送 `metin2.bin` 和旧 timestamp,未读取实际
|
||||||
构建版本、文件时间或资源包版本。服务端开启版本校验时,更新客户端后仍可能被当成旧客户端。
|
构建版本、文件时间或资源包版本。服务端开启版本校验时,更新客户端后仍可能被当成旧客户端。
|
||||||
见 [`classic_session.cpp:335`](/Users/shenlei/Work/mt/mtgodot-poc/extension/src/net/classic/classic_session.cpp:335)。
|
见 [`classic_session.cpp:335`](/Users/shenlei/Work/mt/metin2-client/extension/src/net/classic/classic_session.cpp:335)。
|
||||||
|
|
||||||
6. **Passpod、HackShield、XTrap、StateChecker 等兼容路径没有实现。** 当前选择了不伪造
|
6. **Passpod、HackShield、XTrap、StateChecker 等兼容路径没有实现。** 当前选择了不伪造
|
||||||
这些回应;如果目标 40250 服务端启用了其中任意一项,登录或进场可能被断开。应把“不支持”
|
这些回应;如果目标 40250 服务端启用了其中任意一项,登录或进场可能被断开。应把“不支持”
|
||||||
@@ -2121,12 +2121,12 @@ Python 脚本;文档中出现的 `assets/root/*.py` 均属于本工程迁移
|
|||||||
7. **未知包的 Loading 重同步问题已修复第一批。** 当前不再扫描后续字节寻找
|
7. **未知包的 Loading 重同步问题已修复第一批。** 当前不再扫描后续字节寻找
|
||||||
`GC_CHARACTER_ADD` 边界;未知 header 记录最近包、清空缓冲并断开,保留 `header == 0`
|
`GC_CHARACTER_ADD` 边界;未知 header 记录最近包、清空缓冲并断开,保留 `header == 0`
|
||||||
填充跳过。真实加密流验收仍待完成。见
|
填充跳过。真实加密流验收仍待完成。见
|
||||||
[`classic_stream.cpp`](/Users/shenlei/Work/mt/mtgodot-poc/extension/src/net/classic/classic_stream.cpp)。
|
[`classic_stream.cpp`](/Users/shenlei/Work/mt/metin2-client/extension/src/net/classic/classic_stream.cpp)。
|
||||||
|
|
||||||
8. **已知但没有业务处理的包静默丢弃问题已修复第一批。** `ClassicParser` 现在记录阶段 /
|
8. **已知但没有业务处理的包静默丢弃问题已修复第一批。** `ClassicParser` 现在记录阶段 /
|
||||||
header 并返回失败,连接层清空缓冲并断开;可选集成包不会再静默成功。按阶段拆分处理表和
|
header 并返回失败,连接层清空缓冲并断开;可选集成包不会再静默成功。按阶段拆分处理表和
|
||||||
全量业务覆盖仍待完成。见
|
全量业务覆盖仍待完成。见
|
||||||
[`classic_parser.cpp`](/Users/shenlei/Work/mt/mtgodot-poc/extension/src/net/classic/classic_parser.cpp)。
|
[`classic_parser.cpp`](/Users/shenlei/Work/mt/metin2-client/extension/src/net/classic/classic_parser.cpp)。
|
||||||
|
|
||||||
9. **封包覆盖仍停留在结构和尺寸层。** 还需要逐包核对 sub-header、动态 body、实体状态、
|
9. **封包覆盖仍停留在结构和尺寸层。** 还需要逐包核对 sub-header、动态 body、实体状态、
|
||||||
UI 消费、发送后的锁定状态、失败回滚和重连恢复,不能仅以 `Packet.h` 中存在 header 作为完成。
|
UI 消费、发送后的锁定状态、失败回滚和重连恢复,不能仅以 `Packet.h` 中存在 header 作为完成。
|
||||||
@@ -2144,17 +2144,17 @@ Python 脚本;文档中出现的 `assets/root/*.py` 均属于本工程迁移
|
|||||||
`mut_dead`、`mut_stun`、物品更新等路径遇到未知实体直接返回,没有 pending state 或重放机制。
|
`mut_dead`、`mut_stun`、物品更新等路径遇到未知实体直接返回,没有 pending state 或重放机制。
|
||||||
角色添加顺序变化时,位置、HP、死亡、动作和装备信息会丢失;`GC_SHOP_SIGN` 当前有独立的
|
角色添加顺序变化时,位置、HP、死亡、动作和装备信息会丢失;`GC_SHOP_SIGN` 当前有独立的
|
||||||
招牌 fallback,不能与普通 Actor 更新混为一谈。见
|
招牌 fallback,不能与普通 Actor 更新混为一谈。见
|
||||||
[`entity_store.cpp:123`](/Users/shenlei/Work/mt/mtgodot-poc/extension/src/net/entity_store.cpp:123)。
|
[`entity_store.cpp:123`](/Users/shenlei/Work/mt/metin2-client/extension/src/net/entity_store.cpp:123)。
|
||||||
|
|
||||||
2. **`GC_CHAR_ADDITIONAL_INFO` 早于 Spawn 时状态不会被待合并缓存。** `mut_char_info()` 可以
|
2. **`GC_CHAR_ADDITIONAL_INFO` 早于 Spawn 时状态不会被待合并缓存。** `mut_char_info()` 可以
|
||||||
创建 / 更新 Entity,但新建时不发送 Spawn change;如果后续 Spawn 正常到达,NetWorld 仍会
|
创建 / 更新 Entity,但新建时不发送 Spawn change;如果后续 Spawn 正常到达,NetWorld 仍会
|
||||||
创建节点,若 Spawn 丢失或顺序异常则实体会长期只有数据没有表现层节点。见
|
创建节点,若 Spawn 丢失或顺序异常则实体会长期只有数据没有表现层节点。见
|
||||||
[`entity_store.cpp:458`](/Users/shenlei/Work/mt/mtgodot-poc/extension/src/net/entity_store.cpp:458)、
|
[`entity_store.cpp:458`](/Users/shenlei/Work/mt/metin2-client/extension/src/net/entity_store.cpp:458)、
|
||||||
[`net_world.gd:128`](/Users/shenlei/Work/mt/mtgodot-poc/project/net_world.gd:128)。
|
[`net_world.gd:128`](/Users/shenlei/Work/mt/metin2-client/project/net_world.gd:128)。
|
||||||
|
|
||||||
3. **VID 重用时旧状态可能污染新实体。** `mut_spawn()` 没有完整重置 dead、stunned、mount、
|
3. **VID 重用时旧状态可能污染新实体。** `mut_spawn()` 没有完整重置 dead、stunned、mount、
|
||||||
guild、pk、alignment、parts、shop sign、affect 和上一动作状态,只覆盖了基础出生字段。
|
guild、pk、alignment、parts、shop sign、affect 和上一动作状态,只覆盖了基础出生字段。
|
||||||
见 [`entity_store.cpp:75`](/Users/shenlei/Work/mt/mtgodot-poc/extension/src/net/entity_store.cpp:75)。
|
见 [`entity_store.cpp:75`](/Users/shenlei/Work/mt/metin2-client/extension/src/net/entity_store.cpp:75)。
|
||||||
|
|
||||||
4. **点数组 API 对部分更新可能残留旧值。** `mut_set_points()` 只复制本次传入数量,不清理
|
4. **点数组 API 对部分更新可能残留旧值。** `mut_set_points()` 只复制本次传入数量,不清理
|
||||||
上一次更长数组的尾部;不过当前固定长度 Classic 包路径传入 `POINT_MAX_NUM`,该路径不会
|
上一次更长数组的尾部;不过当前固定长度 Classic 包路径传入 `POINT_MAX_NUM`,该路径不会
|
||||||
@@ -2163,7 +2163,7 @@ Python 脚本;文档中出现的 `assets/root/*.py` 均属于本工程迁移
|
|||||||
5. **实体数据字段不完整。** empire、称号、公会名、公会徽章、名字颜色、alignment 颜色、
|
5. **实体数据字段不完整。** empire、称号、公会名、公会徽章、名字颜色、alignment 颜色、
|
||||||
target highlight、attackable、weapon mode、sex/job/race motion mode、affect 图标和
|
target highlight、attackable、weapon mode、sex/job/race motion mode、affect 图标和
|
||||||
剩余时间没有形成完整统一的实体状态。`mut_char_info()` 当前还明确忽略 `empire`。
|
剩余时间没有形成完整统一的实体状态。`mut_char_info()` 当前还明确忽略 `empire`。
|
||||||
见 [`entity_store.cpp:477`](/Users/shenlei/Work/mt/mtgodot-poc/extension/src/net/entity_store.cpp:477)。
|
见 [`entity_store.cpp:477`](/Users/shenlei/Work/mt/metin2-client/extension/src/net/entity_store.cpp:477)。
|
||||||
|
|
||||||
6. **一次性事件没有可靠的生命周期缓存。** 聊天、脚本、特效、飞行物、任务提示等事件主要
|
6. **一次性事件没有可靠的生命周期缓存。** 聊天、脚本、特效、飞行物、任务提示等事件主要
|
||||||
通过 drain queue 即时消费;如果 UI 或 GameScene 尚未完成装配,事件可能被消费后无法补发。
|
通过 drain queue 即时消费;如果 UI 或 GameScene 尚未完成装配,事件可能被消费后无法补发。
|
||||||
@@ -2185,7 +2185,7 @@ Python 脚本;文档中出现的 `assets/root/*.py` 均属于本工程迁移
|
|||||||
`isLock` / `IsUsingMovingSkill`)未过时存 `_reserved_ground` + `RESERVED_GROUND_DELAY` 0.1 s
|
`isLock` / `IsUsingMovingSkill`)未过时存 `_reserved_ground` + `RESERVED_GROUND_DELAY` 0.1 s
|
||||||
延迟重试(≈ `__ReserveClickGround` / `NEW_IsEmptyReservedDelayTime`),`moving_skill` 只转向。
|
延迟重试(≈ `__ReserveClickGround` / `NEW_IsEmptyReservedDelayTime`),`moving_skill` 只转向。
|
||||||
WASD 分支置 `_is_going = false`(≈ `NEW_MoveToDirection`)。见
|
WASD 分支置 `_is_going = false`(≈ `NEW_MoveToDirection`)。见
|
||||||
[`player_controller.gd`](/Users/shenlei/Work/mt/mtgodot-poc/project/player_controller.gd) +
|
[`player_controller.gd`](/Users/shenlei/Work/mt/metin2-client/project/player_controller.gd) +
|
||||||
`project/player_move_test.gd`。**增量 93 补完 Actor-Actor 碰撞 + 坐骑转向速度**:
|
`project/player_move_test.gd`。**增量 93 补完 Actor-Actor 碰撞 + 坐骑转向速度**:
|
||||||
`player_controller._actor_blocked(next_pos)` 近似 `CActorInstance::TestActorCollision`
|
`player_controller._actor_blocked(next_pos)` 近似 `CActorInstance::TestActorCollision`
|
||||||
(`ActorInstanceCollisionDetection.cpp`)+ `CInstanceBase::CheckAdvancing`
|
(`ActorInstanceCollisionDetection.cpp`)+ `CInstanceBase::CheckAdvancing`
|
||||||
@@ -2243,7 +2243,7 @@ Python 脚本;文档中出现的 `assets/root/*.py` 均属于本工程迁移
|
|||||||
+ `CInstanceBase::__GetBowRange` `InstanceBase.cpp:694`)。仍未接:
|
+ `CInstanceBase::__GetBowRange` `InstanceBase.cpp:694`)。仍未接:
|
||||||
`AttackingData` / `HitPosition` 驱动的命中帧 / 硬直 / 无敌帧的真实动态圆柱扫掠(§3.5,
|
`AttackingData` / `HitPosition` 驱动的命中帧 / 硬直 / 无敌帧的真实动态圆柱扫掠(§3.5,
|
||||||
当前 reach + 正面弧近似)、mount 与武器规则、服务端合法性校准。见
|
当前 reach + 正面弧近似)、mount 与武器规则、服务端合法性校准。见
|
||||||
[`net_play.gd`](/Users/shenlei/Work/mt/mtgodot-poc/project/net_play.gd:200)。
|
[`net_play.gd`](/Users/shenlei/Work/mt/metin2-client/project/net_play.gd:200)。
|
||||||
|
|
||||||
4. **自动攻击目标预留逻辑已移植(增量 75)。** ~~当前主要是检测距离后发攻击包~~ —— `net_play.gd`
|
4. **自动攻击目标预留逻辑已移植(增量 75)。** ~~当前主要是检测距离后发攻击包~~ —— `net_play.gd`
|
||||||
新增 `enum ReservedMode` + `_update_auto_attack()`(照 `PythonPlayer.cpp:207`,检查目标存在 /
|
新增 `enum ReservedMode` + `_update_auto_attack()`(照 `PythonPlayer.cpp:207`,检查目标存在 /
|
||||||
@@ -2362,8 +2362,8 @@ Python 脚本;文档中出现的 `assets/root/*.py` 均属于本工程迁移
|
|||||||
`vel.y`= `-Y`;怪物球体碰撞 / `CullingMgr.ForInRange` / 建筑体素 r=1 判定未移植(客户端
|
`vel.y`= `-Y`;怪物球体碰撞 / `CullingMgr.ForInRange` / 建筑体素 r=1 判定未移植(客户端
|
||||||
飞行物只做命中"时机",伤害服务端裁决);无 `.fly` 数据文件加载器 → 合成箭参数。仍待:真服
|
飞行物只做命中"时机",伤害服务端裁决);无 `.fly` 数据文件加载器 → 合成箭参数。仍待:真服
|
||||||
`CG_FLY_TARGETING` z 分量、`.fly` 数据文件解析。见
|
`CG_FLY_TARGETING` z 分量、`.fly` 数据文件解析。见
|
||||||
[`fly_object.gd`](/Users/shenlei/Work/mt/mtgodot-poc/project/fly_object.gd) /
|
[`fly_object.gd`](/Users/shenlei/Work/mt/metin2-client/project/fly_object.gd) /
|
||||||
[`game_scene.gd`](/Users/shenlei/Work/mt/mtgodot-poc/project/game_scene.gd)
|
[`game_scene.gd`](/Users/shenlei/Work/mt/metin2-client/project/game_scene.gd)
|
||||||
`_queue_shot` / `_on_local_motion_event`。
|
`_queue_shot` / `_on_local_motion_event`。
|
||||||
|
|
||||||
7. **受击状态分裂。** ~~`EntityStore.stunned` 和 `NetPlay._hitstun_until` 是两套互不统一的状态~~
|
7. **受击状态分裂。** ~~`EntityStore.stunned` 和 `NetPlay._hitstun_until` 是两套互不统一的状态~~
|
||||||
@@ -2443,7 +2443,7 @@ Python 脚本;文档中出现的 `assets/root/*.py` 均属于本工程迁移
|
|||||||
`mtnet::bounds::skill_index`(与 `use_skill` 同一谓词)。升级结果由 `GC_SKILL_LEVEL`
|
`mtnet::bounds::skill_index`(与 `use_skill` 同一谓词)。升级结果由 `GC_SKILL_LEVEL`
|
||||||
经 `skills_changed` 驱动刷新,不本地伪造。`skill_test.gd` +`§3.10` 段。
|
经 `skills_changed` 驱动刷新,不本地伪造。`skill_test.gd` +`§3.10` 段。
|
||||||
C++ **21/21** + **13** GDScript 全绿。见
|
C++ **21/21** + **13** GDScript 全绿。见
|
||||||
[`m2_client.cpp:961`](/Users/shenlei/Work/mt/mtgodot-poc/extension/src/net/m2_client.cpp:961)、
|
[`m2_client.cpp:961`](/Users/shenlei/Work/mt/metin2-client/extension/src/net/m2_client.cpp:961)、
|
||||||
参考 [`PythonSkill.cpp:1833`](/Users/shenlei/Work/mt/40250/ClientVS22/source/UserInterface/PythonSkill.cpp:1833)、
|
参考 [`PythonSkill.cpp:1833`](/Users/shenlei/Work/mt/40250/ClientVS22/source/UserInterface/PythonSkill.cpp:1833)、
|
||||||
[`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §3.10 / C.5。
|
[`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §3.10 / C.5。
|
||||||
|
|
||||||
@@ -2505,10 +2505,10 @@ Python 脚本;文档中出现的 `assets/root/*.py` 均属于本工程迁移
|
|||||||
`game_scene` 对 `ch_type==0` 创建 `RemotePlayerView`,复用真实 `PlayerView` race / GR2 / MSA;
|
`game_scene` 对 `ch_type==0` 创建 `RemotePlayerView`,复用真实 `PlayerView` race / GR2 / MSA;
|
||||||
远端 `EquipModel` 只消费 `awPart[ARMOR,WEAPON,HEAD,HAIR]`,`net_world` 按四字段调用 setter,
|
远端 `EquipModel` 只消费 `awPart[ARMOR,WEAPON,HEAD,HAIR]`,`net_world` 按四字段调用 setter,
|
||||||
不读取本地 `get_equipment()`。所有职业 / race、变身字段、远端真服换装和精确视觉仍待验收。见
|
不读取本地 `get_equipment()`。所有职业 / race、变身字段、远端真服换装和精确视觉仍待验收。见
|
||||||
[`inventory_ui.gd`](/Users/shenlei/Work/mt/mtgodot-poc/project/ui/inventory_ui.gd) /
|
[`inventory_ui.gd`](/Users/shenlei/Work/mt/metin2-client/project/ui/inventory_ui.gd) /
|
||||||
[`equip_rules.gd`](/Users/shenlei/Work/mt/mtgodot-poc/project/equip_rules.gd) /
|
[`equip_rules.gd`](/Users/shenlei/Work/mt/metin2-client/project/equip_rules.gd) /
|
||||||
[`part_hiding.gd`](/Users/shenlei/Work/mt/mtgodot-poc/project/part_hiding.gd) /
|
[`part_hiding.gd`](/Users/shenlei/Work/mt/metin2-client/project/part_hiding.gd) /
|
||||||
[`equip_model.gd`](/Users/shenlei/Work/mt/mtgodot-poc/project/ui/equip_model.gd),详见
|
[`equip_model.gd`](/Users/shenlei/Work/mt/metin2-client/project/ui/equip_model.gd),详见
|
||||||
[`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §4.1 / C.5。
|
[`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §4.1 / C.5。
|
||||||
|
|
||||||
2. **物品拖放已有全局首版,但还不是完整原版鼠标物品态。** 已有全局持有物品、鼠标跟随图标、
|
2. **物品拖放已有全局首版,但还不是完整原版鼠标物品态。** 已有全局持有物品、鼠标跟随图标、
|
||||||
@@ -2529,7 +2529,7 @@ Python 脚本;文档中出现的 `assets/root/*.py` 均属于本工程迁移
|
|||||||
`/safebox_close`(`cmd.cpp:350`,对齐原版关窗即服务器侧关闭);改密走
|
`/safebox_close`(`cmd.cpp:350`,对齐原版关窗即服务器侧关闭);改密走
|
||||||
`/safebox_change_password <old> <new>`(`cmd.cpp:354`,各 1..6 位)三段密码 ConfirmationDialog;
|
`/safebox_change_password <old> <new>`(`cmd.cpp:354`,各 1..6 位)三段密码 ConfirmationDialog;
|
||||||
`mall_ui.close()` 同步补 `/mall_close`。仍缺 45 格网格、分页、指定目标格、仓库内移动、重试
|
`mall_ui.close()` 同步补 `/mall_close`。仍缺 45 格网格、分页、指定目标格、仓库内移动、重试
|
||||||
限制和原版互斥规则。见 [`safebox_ui.gd:7`](/Users/shenlei/Work/mt/mtgodot-poc/project/ui/safebox_ui.gd:7)。
|
限制和原版互斥规则。见 [`safebox_ui.gd:7`](/Users/shenlei/Work/mt/metin2-client/project/ui/safebox_ui.gd:7)。
|
||||||
|
|
||||||
5. **交易结束 / 失败提示已补(增量 129),事务状态仍缺。** 已核实 `GC_END` 对成功与取消发同码
|
5. **交易结束 / 失败提示已补(增量 129),事务状态仍缺。** 已核实 `GC_END` 对成功与取消发同码
|
||||||
(`exchange.cpp` `EXCHANGE_END`→`Cancel()`),包级不可区分——`EntityStore.ExchangeState` 新增
|
(`exchange.cpp` `EXCHANGE_END`→`Cancel()`),包级不可区分——`EntityStore.ExchangeState` 新增
|
||||||
@@ -2551,7 +2551,7 @@ Python 脚本;文档中出现的 `assets/root/*.py` 均属于本工程迁移
|
|||||||
置灰 + 激活守卫)、`affect_added`/`affect_removed`(affect 220 = 变身,技能槽置灰)、
|
置灰 + 激活守卫)、`affect_added`/`affect_removed`(affect 220 = 变身,技能槽置灰)、
|
||||||
`inventory_changed`(物品槽 `get_item` 为空 / vnum==0 时置灰并显示 `×vnum`,覆盖换武器 /
|
`inventory_changed`(物品槽 `get_item` 为空 / vnum==0 时置灰并显示 `×vnum`,覆盖换武器 /
|
||||||
服务器回收)。仍缺特殊技能冷却。见
|
服务器回收)。仍缺特殊技能冷却。见
|
||||||
[`quickbar.gd`](/Users/shenlei/Work/mt/mtgodot-poc/project/ui/quickbar.gd)。
|
[`quickbar.gd`](/Users/shenlei/Work/mt/metin2-client/project/ui/quickbar.gd)。
|
||||||
|
|
||||||
9. **组队状态是近似定义。** affect 标签仍有暂定映射,队伍技能冷却、队长权限动态变化、离线、
|
9. **组队状态是近似定义。** affect 标签仍有暂定映射,队伍技能冷却、队长权限动态变化、离线、
|
||||||
死亡、距离状态和多账号行为还未完成真实验收。
|
死亡、距离状态和多账号行为还未完成真实验收。
|
||||||
@@ -2599,8 +2599,8 @@ Python 脚本;文档中出现的 `assets/root/*.py` 均属于本工程迁移
|
|||||||
`iConfirmEndTime` 的 `timeGetTime`、`.msc` 事件文件加载(`RUN_CINEMA` 换文件)、`MakeNextButton`
|
`iConfirmEndTime` 的 `timeGetTime`、`.msc` 事件文件加载(`RUN_CINEMA` 换文件)、`MakeNextButton`
|
||||||
NEXT vs DONE 文案区分 + `DoneEvent()` 回调、`camera_event_requested` / `fade_event_requested` /
|
NEXT vs DONE 文案区分 + `DoneEvent()` 回调、`camera_event_requested` / `fade_event_requested` /
|
||||||
`cinema_requested` 消费端与 §7.6 相机模式对接、物品名颜色 `(1.0,0.2,0.2)`。见
|
`cinema_requested` 消费端与 §7.6 相机模式对接、物品名颜色 `(1.0,0.2,0.2)`。见
|
||||||
[`quest_dialog.gd`](/Users/shenlei/Work/mt/mtgodot-poc/project/ui/quest_dialog.gd) /
|
[`quest_dialog.gd`](/Users/shenlei/Work/mt/metin2-client/project/ui/quest_dialog.gd) /
|
||||||
[`quest_event_test.gd`](/Users/shenlei/Work/mt/mtgodot-poc/project/quest_event_test.gd)。
|
[`quest_event_test.gd`](/Users/shenlei/Work/mt/metin2-client/project/quest_event_test.gd)。
|
||||||
|
|
||||||
2. **任务日志已补 5 行窗口、滚动、默认图标、倒计时和独立 QuestButton 条带,但不是完整 Quest
|
2. **任务日志已补 5 行窗口、滚动、默认图标、倒计时和独立 QuestButton 条带,但不是完整 Quest
|
||||||
系统。** 增量 101 按参考端 `QUEST_MAX_NUM=5` 固定显示 5 行;超过 5 条滚动到离散的
|
系统。** 增量 101 按参考端 `QUEST_MAX_NUM=5` 固定显示 5 行;超过 5 条滚动到离散的
|
||||||
@@ -2899,13 +2899,13 @@ Python 脚本;文档中出现的 `assets/root/*.py` 均属于本工程迁移
|
|||||||
|
|
||||||
1. **本地角色先显示胶囊占位体。** GameScene 初始化阶段先创建 Capsule,真实角色稍后加载,
|
1. **本地角色先显示胶囊占位体。** GameScene 初始化阶段先创建 Capsule,真实角色稍后加载,
|
||||||
会造成模型闪烁、相机目标高度变化、碰撞体和阴影重新挂接。见
|
会造成模型闪烁、相机目标高度变化、碰撞体和阴影重新挂接。见
|
||||||
[`game_scene.gd:136`](/Users/shenlei/Work/mt/mtgodot-poc/project/game_scene.gd:136)、
|
[`game_scene.gd:136`](/Users/shenlei/Work/mt/metin2-client/project/game_scene.gd:136)、
|
||||||
[`game_scene.gd:811`](/Users/shenlei/Work/mt/mtgodot-poc/project/game_scene.gd:811)。
|
[`game_scene.gd:811`](/Users/shenlei/Work/mt/metin2-client/project/game_scene.gd:811)。
|
||||||
|
|
||||||
2. **SpeedTree 仍是程序化代理。** 当前只嗅探 bark/composite 贴图,叶簇 UV 和枝干使用 proxy,
|
2. **SpeedTree 仍是程序化代理。** 当前只嗅探 bark/composite 贴图,叶簇 UV 和枝干使用 proxy,
|
||||||
没有真实 `.spt` 几何、leaf table、LOD、impostor、风参数和原始阴影。见
|
没有真实 `.spt` 几何、leaf table、LOD、impostor、风参数和原始阴影。见
|
||||||
[`tree_placeholder.cpp:190`](/Users/shenlei/Work/mt/mtgodot-poc/extension/src/tree_placeholder.cpp:190)、
|
[`tree_placeholder.cpp:190`](/Users/shenlei/Work/mt/metin2-client/extension/src/tree_placeholder.cpp:190)、
|
||||||
[`PARITY-GAP.md:77`](/Users/shenlei/Work/mt/mtgodot-poc/docs/PARITY-GAP.md:77)。
|
[`PARITY-GAP.md:77`](/Users/shenlei/Work/mt/metin2-client/docs/PARITY-GAP.md:77)。
|
||||||
|
|
||||||
3. **EffectLib 已从粒子近似推进到 `.mde` 网格子集。** `.mse` Particle 已映射到
|
3. **EffectLib 已从粒子近似推进到 `.mde` 网格子集。** `.mse` Particle 已映射到
|
||||||
`GPUParticles3D`,现在会读取首个 `TextureFiles` 的实际 `.dds`、位置、发射形状、方向 / 速度;
|
`GPUParticles3D`,现在会读取首个 `TextureFiles` 的实际 `.dds`、位置、发射形状、方向 / 速度;
|
||||||
@@ -2914,8 +2914,8 @@ Python 脚本;文档中出现的 `assets/root/*.py` 均属于本工程迁移
|
|||||||
接入 SimpleLight 的范围 / 生命周期曲线和 TexAni 基础帧延迟 / 顺逆序;增量 66 接入统一的
|
接入 SimpleLight 的范围 / 生命周期曲线和 TexAni 基础帧延迟 / 顺逆序;增量 66 接入统一的
|
||||||
MovingType 直接段 / 二次 Bezier 位置曲线,并让粒子、网格、Light 共用。仍缺 TexAni 随机起始帧 /
|
MovingType 直接段 / 二次 Bezier 位置曲线,并让粒子、网格、Light 共用。仍缺 TexAni 随机起始帧 /
|
||||||
每粒子语义、完整 emitter advanced / cycle、完整时间轴、骨骼挂点和真实资源逐项验收。见
|
每粒子语义、完整 emitter advanced / cycle、完整时间轴、骨骼挂点和真实资源逐项验收。见
|
||||||
[`effect_player.gd`](/Users/shenlei/Work/mt/mtgodot-poc/project/fx/effect_player.gd)、
|
[`effect_player.gd`](/Users/shenlei/Work/mt/metin2-client/project/fx/effect_player.gd)、
|
||||||
[`mde.gd`](/Users/shenlei/Work/mt/mtgodot-poc/project/fx/mde.gd)。
|
[`mde.gd`](/Users/shenlei/Work/mt/metin2-client/project/fx/mde.gd)。
|
||||||
|
|
||||||
4. **GR2 运行时不是完整 ActorInstance。** 缺 race/weapon/sex motion mode、loop/interrupt/
|
4. **GR2 运行时不是完整 ActorInstance。** 缺 race/weapon/sex motion mode、loop/interrupt/
|
||||||
accumulation、action reservation、mount motion、武器轨迹、击退、倒地、起身和完整死亡动作。
|
accumulation、action reservation、mount motion、武器轨迹、击退、倒地、起身和完整死亡动作。
|
||||||
@@ -2926,18 +2926,18 @@ Python 脚本;文档中出现的 `assets/root/*.py` 均属于本工程迁移
|
|||||||
|
|
||||||
6. **材质是固定功能近似。** 缺精确 alpha-test ref、多贴图 stage、color operation、透明排序、
|
6. **材质是固定功能近似。** 缺精确 alpha-test ref、多贴图 stage、color operation、透明排序、
|
||||||
角色专属光照和逐 surface 状态;sphere-map 默认 `spec_power = 0`,没有完整由装备数据驱动。
|
角色专属光照和逐 surface 状态;sphere-map 默认 `spec_power = 0`,没有完整由装备数据驱动。
|
||||||
见 [`m2_material.h:39`](/Users/shenlei/Work/mt/mtgodot-poc/extension/src/m2_material.h:39)。
|
见 [`m2_material.h:39`](/Users/shenlei/Work/mt/metin2-client/extension/src/m2_material.h:39)。
|
||||||
|
|
||||||
### 7. P1/P2:地图、环境和相机
|
### 7. P1/P2:地图、环境和相机
|
||||||
|
|
||||||
1. **静态地图只完整接入 Building / DungeonBlock。** Effect、Ambience、地图机关、透明物排序、
|
1. **静态地图只完整接入 Building / DungeonBlock。** Effect、Ambience、地图机关、透明物排序、
|
||||||
portal/area、对象脚本状态和对象 LOD 没有完整迁移。见
|
portal/area、对象脚本状态和对象 LOD 没有完整迁移。见
|
||||||
[`metin2_world.cpp:367`](/Users/shenlei/Work/mt/mtgodot-poc/extension/src/metin2_world.cpp:367)。
|
[`metin2_world.cpp:367`](/Users/shenlei/Work/mt/metin2-client/extension/src/metin2_world.cpp:367)。
|
||||||
|
|
||||||
2. **Pack 资源读取没有统一。** 虽然 `AssetSource`/`PackMount` 已存在,World、terrain、tree、
|
2. **Pack 资源读取没有统一。** 虽然 `AssetSource`/`PackMount` 已存在,World、terrain、tree、
|
||||||
water 和 static object 仍有直接 `AssetResolver::resolve()` 的路径,散文件和 `.epk/.eix` 运行
|
water 和 static object 仍有直接 `AssetResolver::resolve()` 的路径,散文件和 `.epk/.eix` 运行
|
||||||
结果可能不同。见 [`metin2_world.cpp:380`](/Users/shenlei/Work/mt/mtgodot-poc/extension/src/metin2_world.cpp:380)、
|
结果可能不同。见 [`metin2_world.cpp:380`](/Users/shenlei/Work/mt/metin2-client/extension/src/metin2_world.cpp:380)、
|
||||||
[`terrain_splat.cpp:133`](/Users/shenlei/Work/mt/mtgodot-poc/extension/src/terrain_splat.cpp:133)。
|
[`terrain_splat.cpp:133`](/Users/shenlei/Work/mt/metin2-client/extension/src/terrain_splat.cpp:133)。
|
||||||
|
|
||||||
3. **地形 splat 虽已支持 16 层,仍非完整固定功能复现。** alpha 含义、texture wrap/filter、
|
3. **地形 splat 虽已支持 16 层,仍非完整固定功能复现。** alpha 含义、texture wrap/filter、
|
||||||
多 stage、patch geometry LOD、skirt、patch 碰撞和 baked shadow mask 叠加规则仍未完成。
|
多 stage、patch geometry LOD、skirt、patch 碰撞和 baked shadow mask 叠加规则仍未完成。
|
||||||
@@ -2954,7 +2954,7 @@ Python 脚本;文档中出现的 `assets/root/*.py` 均属于本工程迁移
|
|||||||
|
|
||||||
6. **相机不是固定基线机位。** 当前依赖脚本中的 FOV、yaw、pitch、距离和 AABB 射线,缺
|
6. **相机不是固定基线机位。** 当前依赖脚本中的 FOV、yaw、pitch、距离和 AABB 射线,缺
|
||||||
`CameraProcedure` 等价模式、灵敏度持久化、平滑碰撞、树木遮挡淡出、目标 Actor 状态和可回放
|
`CameraProcedure` 等价模式、灵敏度持久化、平滑碰撞、树木遮挡淡出、目标 Actor 状态和可回放
|
||||||
的受控机位。见 [`PARITY-GAP.md:195`](/Users/shenlei/Work/mt/mtgodot-poc/docs/PARITY-GAP.md:195)。
|
的受控机位。见 [`PARITY-GAP.md:195`](/Users/shenlei/Work/mt/metin2-client/docs/PARITY-GAP.md:195)。
|
||||||
|
|
||||||
### 8. P1/P2:UI、输入和本地化
|
### 8. P1/P2:UI、输入和本地化
|
||||||
|
|
||||||
@@ -2968,7 +2968,7 @@ Python 脚本;文档中出现的 `assets/root/*.py` 均属于本工程迁移
|
|||||||
|
|
||||||
3. **帮助窗口已有首版,仍缺视觉等价验收。** `help_ui.gd` 已装载 `helpwindow.py` 的布局和帮助
|
3. **帮助窗口已有首版,仍缺视觉等价验收。** `help_ui.gd` 已装载 `helpwindow.py` 的布局和帮助
|
||||||
文案,H 键及 ESC 菜单入口已接通;仍需 `.sub` 贴图、字体描边和真分辨率视觉验收。见
|
文案,H 键及 ESC 菜单入口已接通;仍需 `.sub` 贴图、字体描边和真分辨率视觉验收。见
|
||||||
[`help_ui.gd`](/Users/shenlei/Work/mt/mtgodot-poc/project/ui/help_ui.gd)。
|
[`help_ui.gd`](/Users/shenlei/Work/mt/metin2-client/project/ui/help_ui.gd)。
|
||||||
|
|
||||||
4. **系统设置部分只是保存数值。** CPU/GPU tiling 不会改变实际渲染,BGM 更换按钮 disabled,
|
4. **系统设置部分只是保存数值。** CPU/GPU tiling 不会改变实际渲染,BGM 更换按钮 disabled,
|
||||||
部分画质设置没有完整接入渲染侧;名字颜色本身已 1:1(增量 110)、头顶称号色 / 公会名 /
|
部分画质设置没有完整接入渲染侧;名字颜色本身已 1:1(增量 110)、头顶称号色 / 公会名 /
|
||||||
@@ -2980,7 +2980,7 @@ Python 脚本;文档中出现的 `assets/root/*.py` 均属于本工程迁移
|
|||||||
「empire 名字颜色」开关尚未接到 `net_world.set_empire_name_mode(bool)`(默认关)、
|
「empire 名字颜色」开关尚未接到 `net_world.set_empire_name_mode(bool)`(默认关)、
|
||||||
`EnablePKTitle` 开关尚未接到 `net_world.set_pk_title_enabled(bool)`(默认开)、
|
`EnablePKTitle` 开关尚未接到 `net_world.set_pk_title_enabled(bool)`(默认开)、
|
||||||
`TextTail_SetLivingTime`(气泡停留秒数)尚无运行期钩子(默认 5000)。见
|
`TextTail_SetLivingTime`(气泡停留秒数)尚无运行期钩子(默认 5000)。见
|
||||||
[`system_option_ui.gd:143`](/Users/shenlei/Work/mt/mtgodot-poc/project/ui/system_option_ui.gd:143)。
|
[`system_option_ui.gd:143`](/Users/shenlei/Work/mt/metin2-client/project/ui/system_option_ui.gd:143)。
|
||||||
|
|
||||||
5. **鼠标物品态和光标已有首版,复杂窗口语义仍不完整。** `mouse_controller.gd` 已提供鼠标跟随
|
5. **鼠标物品态和光标已有首版,复杂窗口语义仍不完整。** `mouse_controller.gd` 已提供鼠标跟随
|
||||||
物品、投放到背包 / 快捷栏、无效区域 / 右键 / ESC / 失焦取消;`cursor_manager.gd` 已提供
|
物品、投放到背包 / 快捷栏、无效区域 / 右键 / ESC / 失焦取消;`cursor_manager.gd` 已提供
|
||||||
@@ -3152,19 +3152,19 @@ Python 脚本;文档中出现的 `assets/root/*.py` 均属于本工程迁移
|
|||||||
|
|
||||||
9. **任务导航和完整资源视觉仍未完成。** 增量 101 已接入默认 / 服务端 icon、5 行滚动、倒计时和
|
9. **任务导航和完整资源视觉仍未完成。** 增量 101 已接入默认 / 服务端 icon、5 行滚动、倒计时和
|
||||||
QuestButton 事件参数;仍缺世界箭头、屏幕边缘指示、多列定位、真实 `.sub` 状态图和服务端
|
QuestButton 事件参数;仍缺世界箭头、屏幕边缘指示、多列定位、真实 `.sub` 状态图和服务端
|
||||||
`iStartTime` 生命周期。见 [`quest_log.gd`](/Users/shenlei/Work/mt/mtgodot-poc/project/ui/quest_log.gd)。
|
`iStartTime` 生命周期。见 [`quest_log.gd`](/Users/shenlei/Work/mt/metin2-client/project/ui/quest_log.gd)。
|
||||||
|
|
||||||
### 9. P1/P2:音频和应用生命周期
|
### 9. P1/P2:音频和应用生命周期
|
||||||
|
|
||||||
1. **地图 BGM 已有首版消费,仍缺真服 / 完整 MilesLib 验收。** `GC_MAIN_CHARACTER3_BGM` 和
|
1. **地图 BGM 已有首版消费,仍缺真服 / 完整 MilesLib 验收。** `GC_MAIN_CHARACTER3_BGM` 和
|
||||||
`GC_MAIN_CHARACTER4_BGM_VOL` 已路由到 `bgm_changed`,`bgm_director.gd` 按服务端名称、
|
`GC_MAIN_CHARACTER4_BGM_VOL` 已路由到 `bgm_changed`,`bgm_director.gd` 按服务端名称、
|
||||||
`musicInfo.fieldMusic` 回落和默认音量选曲;仍缺完整声音事件模型及真实 40250 地图验证。见
|
`musicInfo.fieldMusic` 回落和默认音量选曲;仍缺完整声音事件模型及真实 40250 地图验证。见
|
||||||
[`bgm_director.gd`](/Users/shenlei/Work/mt/mtgodot-poc/project/bgm_director.gd)。
|
[`bgm_director.gd`](/Users/shenlei/Work/mt/metin2-client/project/bgm_director.gd)。
|
||||||
|
|
||||||
2. **后台 BGM 恢复已有首版,仍缺真机验收。** `AppLifecycle` 后台保存并静音音量,回前台恢复
|
2. **后台 BGM 恢复已有首版,仍缺真机验收。** `AppLifecycle` 后台保存并静音音量,回前台恢复
|
||||||
音量和当前曲目状态,不再清空 `_bgm_name`;Android/iOS 前后台切换和系统回收 TCP 后的恢复
|
音量和当前曲目状态,不再清空 `_bgm_name`;Android/iOS 前后台切换和系统回收 TCP 后的恢复
|
||||||
仍未在真机验证。见
|
仍未在真机验证。见
|
||||||
[`app_lifecycle.gd`](/Users/shenlei/Work/mt/mtgodot-poc/project/app_lifecycle.gd)。
|
[`app_lifecycle.gd`](/Users/shenlei/Work/mt/metin2-client/project/app_lifecycle.gd)。
|
||||||
|
|
||||||
3. **生命周期单一属主代码已完成,仍缺平台验收。** 生产入口由 `AppFlow` 持有唯一
|
3. **生命周期单一属主代码已完成,仍缺平台验收。** 生产入口由 `AppFlow` 持有唯一
|
||||||
`AppLifecycle`,`GameScene` 不再创建,`M2Client` 不再直接处理应用暂停 / 恢复通知;旧
|
`AppLifecycle`,`GameScene` 不再创建,`M2Client` 不再直接处理应用暂停 / 恢复通知;旧
|
||||||
|
|||||||
+16
-7
@@ -1,6 +1,12 @@
|
|||||||
# 客户端移植 —— 从素材渲染器到能联机的客户端
|
# 客户端移植 —— 从素材渲染器到能联机的客户端
|
||||||
|
|
||||||
> 起点:`mtgodot-poc` 的渲染纵切「差不多能用」(见 [`PARITY-GAP.md`](./PARITY-GAP.md))。
|
> **适用范围(2026-09-08 复核)**:本文描述的是 **m2dev fork 协议**后端。该后端**已不是默认**——
|
||||||
|
> `project/client_main.gd:14-15` 在 `MT_PROTOCOL` 为空时无条件兜底成 `classic`,当前默认走
|
||||||
|
> 40250 经典协议。迁移方案与 classic 后端的协议细节见 [`CLIENT-40250-PORT.md`](./CLIENT-40250-PORT.md)。
|
||||||
|
> 本文仍然有效的部分:m2dev 后端自身的握手 / 封包 / 相位编排(`MT_PROTOCOL=m2dev` 时仍可切回),
|
||||||
|
> 以及 §2 记录的移植进度。**不要**把本文的封包号或 `Packet.h` 依据用到 classic 后端上。
|
||||||
|
|
||||||
|
> 起点:本仓库(`metin2-client`)的渲染纵切「差不多能用」(见 [`PARITY-GAP.md`](./PARITY-GAP.md))。
|
||||||
> 本文是把它变成**能连服务端玩**的客户端的分层计划。分水岭是**网络层**——别的玩家、
|
> 本文是把它变成**能连服务端玩**的客户端的分层计划。分水岭是**网络层**——别的玩家、
|
||||||
> 物品、战斗、聊天都以封包为数据源。
|
> 物品、战斗、聊天都以封包为数据源。
|
||||||
>
|
>
|
||||||
@@ -9,8 +15,8 @@
|
|||||||
|
|
||||||
## 0. 目标服务端
|
## 0. 目标服务端
|
||||||
|
|
||||||
- LAN:`192.168.21.203`(这台 Mac 能路由到,但握手端口目前 **connection refused** ——
|
- LAN:`192.168.21.203`。~~握手端口 connection refused~~ —— **已解决(2026-08-30)**:真机
|
||||||
服务端进程没起 / 端口和默认不同,需确认)。
|
端到端已打通到 PHASE_GAME,见本文 §3。
|
||||||
- 默认端口(`assets/root/serverinfo.py`):auth `11000`,频道/游戏 `11011/11021/11031/11041`。
|
- 默认端口(`assets/root/serverinfo.py`):auth `11000`,频道/游戏 `11011/11021/11031/11041`。
|
||||||
- 测试账号:`admin` / `123456789`。
|
- 测试账号:`admin` / `123456789`。
|
||||||
|
|
||||||
@@ -217,7 +223,10 @@ AEAD 加密 session token。
|
|||||||
|
|
||||||
## 4. 下一步
|
## 4. 下一步
|
||||||
|
|
||||||
1. 打通到 `.203` 的连通性(见上)。可达后立即能测:
|
> **本节 1–4 项已于 2026-08-30 全部完成**(见 §3 与 [`CLIENT-ROADMAP.md`](./CLIENT-ROADMAP.md)
|
||||||
|
> 的 P0–P10)。保留为当时的计划记录,不要再当作待办执行。
|
||||||
|
|
||||||
|
1. ~~打通到 `.203` 的连通性~~ ✅。可达后立即能测:
|
||||||
- `./build/extension/net_probe 192.168.21.203 11000 admin 123456789` → 完整握手 + `CGLogin3` → `login_key`
|
- `./build/extension/net_probe 192.168.21.203 11000 admin 123456789` → 完整握手 + `CGLogin3` → `login_key`
|
||||||
- GDScript 里 `M2Client.connect_to_server(...)` → 一路到 `char_list` / `entered_game`
|
- GDScript 里 `M2Client.connect_to_server(...)` → 一路到 `char_list` / `entered_game`
|
||||||
2. **`GCLoginSuccess3` 布局对真实字节校准**:`net_probe` / `M2Client` 收到后 dump 原始字节,
|
2. **`GCLoginSuccess3` 布局对真实字节校准**:`net_probe` / `M2Client` 收到后 dump 原始字节,
|
||||||
@@ -228,15 +237,15 @@ AEAD 加密 session token。
|
|||||||
`Metin2World` 当场景。里程碑「角色连上、站在地图里、别的实体在动」。
|
`Metin2World` 当场景。里程碑「角色连上、站在地图里、别的实体在动」。
|
||||||
4. 输入(点地移动 → `CG_MOVE`)、聊天(`CG_CHAT`/`GC_CHAT` 接 HUD)。
|
4. 输入(点地移动 → `CG_MOVE`)、聊天(`CG_CHAT`/`GC_CHAT` 接 HUD)。
|
||||||
|
|
||||||
## 4. 风险
|
## 5. 风险
|
||||||
|
|
||||||
- **协议版本漂移**:`Packet.h` 结构体要和服务端 build 一致。冻结一对 client/server commit。
|
- **协议版本漂移**:`Packet.h` 结构体要和服务端 build 一致。冻结一对 client/server commit。
|
||||||
- **端到端测试阻塞**:目前握手端口 refused,走不到 `CGLogin3` 之前的验证。
|
- ~~**端到端测试阻塞**:目前握手端口 refused,走不到 `CGLogin3` 之前的验证。~~ 已解除(§3)。
|
||||||
- **移动端 libsodium**:Android/iOS 要静态 vendor(本身可移植,非阻塞)。
|
- **移动端 libsodium**:Android/iOS 要静态 vendor(本身可移植,非阻塞)。
|
||||||
- 自用范围可跳过:商城 / GM 工具 / 部分社交系统。**反作弊(`CG_HACK` / `ProcessCRC` /
|
- 自用范围可跳过:商城 / GM 工具 / 部分社交系统。**反作弊(`CG_HACK` / `ProcessCRC` /
|
||||||
`ProcessScanner`)已在 `CLIENT-GAP.md`「不做清单」标注 ❌ 不做**,除非服务端强制校验。
|
`ProcessScanner`)已在 `CLIENT-GAP.md`「不做清单」标注 ❌ 不做**,除非服务端强制校验。
|
||||||
|
|
||||||
## 5. 移植面(客户端源码)
|
## 6. 移植面(客户端源码)
|
||||||
|
|
||||||
`EterBase/SecureCipher` · `EterLib/{NetStream,ControlPackets,NetPacketHeaderMap}` ·
|
`EterBase/SecureCipher` · `EterLib/{NetStream,ControlPackets,NetPacketHeaderMap}` ·
|
||||||
`UserInterface/{Packet.h, AccountConnector, PythonNetworkStream, PythonNetworkStreamPhase*}` ·
|
`UserInterface/{Packet.h, AccountConnector, PythonNetworkStream, PythonNetworkStreamPhase*}` ·
|
||||||
|
|||||||
@@ -7,6 +7,14 @@
|
|||||||
> 标记:🌐 需服务器端到端 · 💻 离线可做(mock server / 合成封包 / 假 client)
|
> 标记:🌐 需服务器端到端 · 💻 离线可做(mock server / 合成封包 / 假 client)
|
||||||
> 更新日期:2026-08-30 · 自用,无发布门禁
|
> 更新日期:2026-08-30 · 自用,无发布门禁
|
||||||
> **P0–P10 首版全部完成**;`192.168.21.203` 已联通,`net_e2e` 端到端到 PHASE_GAME。里程碑见文末。
|
> **P0–P10 首版全部完成**;`192.168.21.203` 已联通,`net_e2e` 端到端到 PHASE_GAME。里程碑见文末。
|
||||||
|
>
|
||||||
|
> **适用范围(2026-09-08 复核)**:本文整篇以 **m2dev fork 协议**为基线。该后端已不是默认——
|
||||||
|
> `project/client_main.gd:14-15` 默认走 40250 `classic`(见 [`CLIENT-40250-PORT.md`](./CLIENT-40250-PORT.md))。
|
||||||
|
> 玩法拆解、UI 与阶段规划仍然适用;但文末「通用做法」第 4 条「封包号 / 结构以
|
||||||
|
> `m2dev-client-src-main/src/UserInterface/Packet.h` 为准」**只适用于 `MT_PROTOCOL=m2dev`**。
|
||||||
|
> classic 后端的封包依据是 `40250/ClientVS22/source/UserInterface/Packet.h` +
|
||||||
|
> `extension/src/net/classic/wire_classic.h`。上面「已联通」也是指 m2dev 联调服;
|
||||||
|
> 40250 真服(FreeBSD + MySQL)尚未搭起,见 `CLIENT-40250-PORT.md` §7.1。
|
||||||
|
|
||||||
## 已就绪的地基(这些不重复做)
|
## 已就绪的地基(这些不重复做)
|
||||||
|
|
||||||
@@ -348,5 +356,5 @@ PHASE_GAME —— 收到 121 个实体、HP/SP/等级、20 格背包、好友列
|
|||||||
1. **每个 P 先做 💻 部分**:`wire.h` 加封包 + `EntityStore` 解析 + 合成封包单测(`net_entity_test` 风格)+ 假 client 驱动 UI(`netbridge_test` 风格)。
|
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,对字节用。
|
2. 🌐 端到端用 `build/extension/net_e2e`(真机跑 auth→game→选人→进游戏);`MT_NET_TRACE=1` 逐包 header/len/consumed、`MT_NET_DUMP=1` 逐包 hex,对字节用。
|
||||||
3. UI 一律走 P1 的 uiscript 装载器,不手搓布局。
|
3. UI 一律走 P1 的 uiscript 装载器,不手搓布局。
|
||||||
4. 封包号 / 结构以 `../m2dev-client-src-main/src/UserInterface/Packet.h` 为准,`static_assert` 尺寸;**真机字节和参考头文件不符时以真机为准**(已踩:`GC_LOGIN_SUCCESS4`、`bType`、`CG_CLIENT_VERSION`)。
|
4. 封包号 / 结构:`MT_PROTOCOL=m2dev` 时以 `../m2dev-client-src-main/src/UserInterface/Packet.h` 为准,**默认的 `classic` 后端**以 `../40250/ClientVS22/source/UserInterface/Packet.h` + `extension/src/net/classic/wire_classic.h` 为准,`static_assert` 尺寸;**真机字节和参考头文件不符时以真机为准**(已踩:`GC_LOGIN_SUCCESS4`、`bType`、`CG_CLIENT_VERSION`)。
|
||||||
5. 玩法逻辑尽量**服务器驱动、客户端只渲染**(尤其 quest / 商店 / 公会),少搬业务规则。
|
5. 玩法逻辑尽量**服务器驱动、客户端只渲染**(尤其 quest / 商店 / 公会),少搬业务规则。
|
||||||
|
|||||||
+22
-6
@@ -11,7 +11,7 @@
|
|||||||
|
|
||||||
| | |
|
| | |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 状态 | Phase 1(macOS)基本完成,见 [`MIDREVIEW.md`](./MIDREVIEW.md);未完成工作分层清单见 [`BACKLOG.md`](./BACKLOG.md) |
|
| 状态 | **已被工作范围超越(2026-09-08 复核)**。Phase 1(macOS)完成,见 [`MIDREVIEW.md`](./MIDREVIEW.md);Phase 2 的 iOS / Android 交叉编译与 APK 导出也已完成(`BACKLOG.md` F1/F2、[`PLATFORMS.md`](./PLATFORMS.md)),故文中「Phase 2 本轮不做」不再成立。§01「明确排除」清单已大面积失效,见该节的复核注。当前工作以 [`BACKLOG.md`](./BACKLOG.md)、[`CLIENT-GAP.md`](./CLIENT-GAP.md)、[`RENDERING-REPAIR-PLAN.md`](./RENDERING-REPAIR-PLAN.md) 为准 |
|
||||||
| 技术栈 | Godot **4.7.1** stable · GDExtension(`godot-cpp` 精确提交 `101ae38`,4.7 API)· C++20 |
|
| 技术栈 | Godot **4.7.1** stable · GDExtension(`godot-cpp` 精确提交 `101ae38`,4.7 API)· C++20 |
|
||||||
| 目标平台 | **Phase 1:macOS**(当前)→ Phase 2:Android(一加 13 / Vulkan)· iOS(iPhone 16 / Metal)。自用,三台现代设备,不涉及 GLES3 / Compatibility |
|
| 目标平台 | **Phase 1:macOS**(当前)→ Phase 2:Android(一加 13 / Vulkan)· iOS(iPhone 16 / Metal)。自用,三台现代设备,不涉及 GLES3 / Compatibility |
|
||||||
| 判定周期 | Phase 1 ≈ 3.5–5 周(单人)→ **中期评审**;Phase 2 = 三设备各一次 bring-up + 性能留档(自用,无对外 go/no-go) |
|
| 判定周期 | Phase 1 ≈ 3.5–5 周(单人)→ **中期评审**;Phase 2 = 三设备各一次 bring-up + 性能留档(自用,无对外 go/no-go) |
|
||||||
@@ -48,6 +48,22 @@
|
|||||||
|
|
||||||
### 明确排除(两个阶段都不做)
|
### 明确排除(两个阶段都不做)
|
||||||
|
|
||||||
|
> **⚠️ 本清单已失效(2026-09-08 复核)。** 下列 9 项中 8 项后来都做了 —— POC 判定通过后项目
|
||||||
|
> 继续做成了完整客户端,范围扩张是有意的(`SHINSOO-WORLD-RENDERING.md` 的「定位」一节最早
|
||||||
|
> 记录了 UI 这一条)。逐项现状:
|
||||||
|
>
|
||||||
|
> | 原「不做」项 | 现状 | 落点 |
|
||||||
|
> |---|---|---|
|
||||||
|
> | SpeedTree 植被 | 🟡 已做 proxy | `formats/spt.cpp`,`BACKLOG` E10 |
|
||||||
|
> | 地形(Metin2 户外地图) | ✅ 已做 | `Metin2World` + `formats/terrain_mesh.cpp`,`BACKLOG` E8/E11/E12 |
|
||||||
|
> | UI / Python 脚本层 | ✅ 已做 | `project/ui/*`(uiscript 装载器 + 多个 1:1 窗口移植) |
|
||||||
|
> | eterpack | ✅ 已做 | `mtpack` + `packtool`,`BACKLOG` G1 |
|
||||||
|
> | GPU 蒙皮优化 | ✅ 已做 | `MTGODOT_GPUSKIN=1`,`m2_material` `SRC_SKIN` |
|
||||||
|
> | LOD 策略 | ✅ 已做 | `lod_distances` + crossfade,`BACKLOG` C9 |
|
||||||
|
> | 多角色 AI | ⬜ 仍不做 | —— |
|
||||||
|
> | 网络 | ✅ 已做 | `mtnet`(m2dev + 40250 classic 双后端) |
|
||||||
|
> | 玩法 | ✅ 大量已做 | `CLIENT-GAP.md` / `CLIENT-ROADMAP.md` P0–P10 |
|
||||||
|
|
||||||
SpeedTree 植被、地形(Metin2 户外地图)、UI / Python 脚本层、eterpack(Phase 1 用散文件)、GPU 蒙皮优化、LOD 策略、多角色 AI、网络、玩法。
|
SpeedTree 植被、地形(Metin2 户外地图)、UI / Python 脚本层、eterpack(Phase 1 用散文件)、GPU 蒙皮优化、LOD 策略、多角色 AI、网络、玩法。
|
||||||
|
|
||||||
> 地形与 `.mse` 特效在 Phase 1 也不做,但 **M2.5 会做通用 `ShaderMaterial` + 混合模式的地基**,为后续正式移植时的地形 / 特效渲染铺路。
|
> 地形与 `.mse` 特效在 Phase 1 也不做,但 **M2.5 会做通用 `ShaderMaterial` + 混合模式的地基**,为后续正式移植时的地形 / 特效渲染铺路。
|
||||||
@@ -76,7 +92,7 @@ SpeedTree 植被、地形(Metin2 户外地图)、UI / Python 脚本层、ete
|
|||||||
## 02 · 架构总览
|
## 02 · 架构总览
|
||||||
|
|
||||||
```
|
```
|
||||||
mtgodot-poc/
|
metin2-client/ # 建仓时叫 mtgodot-poc,后更名
|
||||||
extension/ GDExtension(C++,godot-cpp 4.7)
|
extension/ GDExtension(C++,godot-cpp 4.7)
|
||||||
src/
|
src/
|
||||||
register_types.cpp 注册 Metin2Model / Metin2AnimPlayer
|
register_types.cpp 注册 Metin2Model / Metin2AnimPlayer
|
||||||
@@ -160,13 +176,13 @@ mtgodot-poc/
|
|||||||
|
|
||||||
**目标**:warrior + 另 2 种骨架的 bind pose + 贴图在 Godot 里正确显示,多部件组装。
|
**目标**:warrior + 另 2 种骨架的 bind pose + 贴图在 Godot 里正确显示,多部件组装。
|
||||||
|
|
||||||
对标 [`steps/M1-static-render.md`](./steps/M1-static-render.md) T2–T5。
|
对标 [`reference/steps/M1-static-render.md`](./reference/steps/M1-static-render.md) T2–T5。
|
||||||
|
|
||||||
| 任务 | 完成判据 |
|
| 任务 | 完成判据 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| T1.1 `gr2_bridge`:`libgr2::Skeleton` → `Skeleton3D`(bone 名 / parent / rest `Transform3D`);basis + 单位缩放在此处理,约定写进注释 | 骨架层级树与 `gr2dump --members` 一致;三种骨架的骨骼数都正确 |
|
| T1.1 `gr2_bridge`:`libgr2::Skeleton` → `Skeleton3D`(bone 名 / parent / rest `Transform3D`);basis + 单位缩放在此处理,约定写进注释 | 骨架层级树与 `gr2dump --members` 一致;三种骨架的骨骼数都正确 |
|
||||||
| T1.2 `libgr2::Mesh` → `ArrayMesh`(`ARRAY_VERTEX/NORMAL/TANGENT/TEX_UV/BONES/WEIGHTS`)+ `Skin`(bind 矩阵);处理 >4 权重 | 白模轮廓 = Blender 里同模型;无破面;三种骨架都过 |
|
| T1.2 `libgr2::Mesh` → `ArrayMesh`(`ARRAY_VERTEX/NORMAL/TANGENT/TEX_UV/BONES/WEIGHTS`)+ `Skin`(bind 矩阵);处理 >4 权重 | 白模轮廓 = Blender 里同模型;无破面;三种骨架都过 |
|
||||||
| T1.3 贴图路径决策(`.dds` 原生导入 vs 运行时 `dds_loader`),实现选定方案;warrior 贴图(实测 512×512 DXT3 5mip) | UV 无错位 / 无镜像;决策写进 `extension/README.md` |
|
| T1.3 贴图路径决策(`.dds` 原生导入 vs 运行时 `dds_loader`),实现选定方案;warrior 贴图(实测 512×512 DXT3 5mip) | UV 无错位 / 无镜像;决策写进 `extension/README.md`(**该文件未产出**,2026-09-08 复核;决策实际记录在 `MIDREVIEW.md` §3「贴图」与 `extension/src/dxt.cpp` 注释里) |
|
||||||
| T1.4 材质:`StandardMaterial3D`(albedo 贴图 + unshaded 先看轮廓),可切白模 | 贴图目视正确 |
|
| T1.4 材质:`StandardMaterial3D`(albedo 贴图 + unshaded 先看轮廓),可切白模 | 贴图目视正确 |
|
||||||
| T1.5 多部件(body / weapon / hair)组装,武器挂 `BoneAttachment3D` | 部件数正确、武器跟手骨 |
|
| T1.5 多部件(body / weapon / hair)组装,武器挂 `BoneAttachment3D` | 部件数正确、武器跟手骨 |
|
||||||
| T1.6 `test/compare.py`:与 bgfx demo 同机位截图 | 几何一致(允许色彩空间 / 光照差异) |
|
| T1.6 `test/compare.py`:与 bgfx demo 同机位截图 | 几何一致(允许色彩空间 / 光照差异) |
|
||||||
@@ -179,7 +195,7 @@ mtgodot-poc/
|
|||||||
|
|
||||||
**目标**:从 GDExtension 每帧驱动 `Skeleton3D` 播动画,含跨文件 retarget 与 motion event。
|
**目标**:从 GDExtension 每帧驱动 `Skeleton3D` 播动画,含跨文件 retarget 与 motion event。
|
||||||
|
|
||||||
对标 [`steps/M2-anim-skinning.md`](./steps/M2-anim-skinning.md)。
|
对标 [`reference/steps/M2-anim-skinning.md`](./reference/steps/M2-anim-skinning.md)。
|
||||||
|
|
||||||
| 任务 | 完成判据 |
|
| 任务 | 完成判据 |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -279,7 +295,7 @@ mtgodot-poc/
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `mtgodot-poc/` 仓库:GDExtension 源 + Godot 4.7.1 demo 工程 | 新仓库 |
|
| `mtgodot-poc/` 仓库:GDExtension 源 + Godot 4.7.1 demo 工程 | 新仓库 |
|
||||||
| macOS 导出配置 | `project/export_presets.cfg` |
|
| macOS 导出配置 | `project/export_presets.cfg` |
|
||||||
| 与 bgfx demo 的几何 / 动画对拍报告 | `test/godot-snapshot.json` |
|
| 与 bgfx demo 的几何 / 动画对拍报告 | ~~`test/godot-snapshot.json`~~ **未产出**(2026-09-08 复核)。实际落地的是 `test/compare.py` + `test/compare-report.json`(进程 / 截图 / 结构硬门禁),与 bgfx 的比对停留在 `test/bgfx-reference/` 的人工交叉核对 |
|
||||||
| 材质差距清单 | 中期评审报告内 |
|
| 材质差距清单 | 中期评审报告内 |
|
||||||
| 50 角色压力采样(供 Phase 2 A/B 决策) | `test/godot-macos-stress.json` |
|
| 50 角色压力采样(供 Phase 2 A/B 决策) | `test/godot-macos-stress.json` |
|
||||||
| **中期评审报告** | 桥接层工作量实测 + 观感对比截图 + 剩余风险 + "是否进入 Phase 2"建议 |
|
| **中期评审报告** | 桥接层工作量实测 + 观感对比截图 + 剩余风险 + "是否进入 Phase 2"建议 |
|
||||||
|
|||||||
+6
-2
@@ -1,5 +1,9 @@
|
|||||||
# Phase 1 中期评审 — mtgodot-poc
|
# Phase 1 中期评审 — metin2-client(评审时仓库名 mtgodot-poc)
|
||||||
|
|
||||||
|
> **本文是一份定格的历史评审记录,不反映现状(2026-09-08 复核)。** 其中的规模数字尤其不要当现状读:
|
||||||
|
> §2 说 `extension/src` ≈ 1997 行 / 6 个文件,实际现在是 **30029 行 / 68 个文件**(网络、世界渲染、
|
||||||
|
> 资产管线都是评审之后加的)。现状看 [`BACKLOG.md`](./BACKLOG.md) 和 [`CLIENT-GAP.md`](./CLIENT-GAP.md)。
|
||||||
|
>
|
||||||
> 日期:2026-08-29(评审后又做了一轮收尾,见 §4/§5 的「已做」标注)· 平台:macOS(Apple M2)· Godot 4.7.1
|
> 日期:2026-08-29(评审后又做了一轮收尾,见 §4/§5 的「已做」标注)· 平台:macOS(Apple M2)· Godot 4.7.1
|
||||||
> · 对标 [`GODOT-POC-PLAN.md`](./GODOT-POC-PLAN.md) §00 Phase 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 纸面对拍这一可选项。
|
> 结论先行:**桥接层工程量小、风险已基本出清,建议进入 Phase 2(一加 13 / iPhone 16 三设备 bring-up,自用)**。M2.5 材质门禁已闭(贴图走 gr2 material 绑定 + 逐 tri_group 拆 surface);仅剩逐材质 oracle 纸面对拍这一可选项。
|
||||||
@@ -51,7 +55,7 @@
|
|||||||
| ~~多材质槽内拆分~~ **→ 已做**:`gr2_bridge.cpp` 加 `build_parts()`,一个 gr2 mesh 有 N>1 个 `tri_groups` 就拆成 N 个 Godot surface(各自 material + 索引子区间)。`build_mesh` 和 CPU 蒙皮路径都按 `RenderPart` 走。shaman(1 mesh→2 surface)、assassin(17→18)实测各面贴图正确。 | 已做 |
|
| ~~多材质槽内拆分~~ **→ 已做**:`gr2_bridge.cpp` 加 `build_parts()`,一个 gr2 mesh 有 N>1 个 `tri_groups` 就拆成 N 个 Godot surface(各自 material + 索引子区间)。`build_mesh` 和 CPU 蒙皮路径都按 `RenderPart` 走。shaman(1 mesh→2 surface)、assassin(17→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)。 | 基本完成 |
|
| ~~对拍未接线~~ **→ 已写 `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 2:GPU 路径跑人群 bench + 多线程构建 + eterpack 共享资产 + 一加 13 / iPhone 16 实测。 |
|
| **性能** | 前台窗口 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 2:GPU 路径跑人群 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 一致,是正确行为不是风险** |
|
| 剩余小项 | ~~ShaderMaterial 静态 Shader 退出泄漏~~ 已修(`cleanup_material_shaders`,`register_types` 卸载时调)· alpha 排序 corner case(`render_priority` 粗排)· ~~背面剔除写死 `cull_back`(Metin2 有 `CULL_NONE` 面)~~ **已做(2026-09-08 复核)**:`m2_material.cpp:188-211` 已有 `cull_disabled` shader 变体,角色 / 武器 / 发型经 `decide_two_sided()` 驱动,见 `BACKLOG.md` B10 · `_frame_model` 对披风类框选偏 | 均不阻塞,见 [`BACKLOG.md`](./BACKLOG.md)(`A3` `A6` `B10` `E5`)。法线用混合矩阵上 3×3 不逆转置 —— **与 Granny runtime 一致,是正确行为不是风险** |
|
||||||
|
|
||||||
## 5. 建议
|
## 5. 建议
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -18,7 +18,7 @@
|
|||||||
**现状**(`world-a1-persp.png` / `world-a1-r2.png`):
|
**现状**(`world-a1-persp.png` / `world-a1-r2.png`):
|
||||||
- ✅ 地貌 / 道路网 / 建筑布局 / 水域形状 = **认得出是神兽国 A1**
|
- ✅ 地貌 / 道路网 / 建筑布局 / 水域形状 = **认得出是神兽国 A1**
|
||||||
- ✅ 第三人称角色(带发型)在地形上跑、HUD 布局在位、`.msenv` 驱动的天空/光/雾
|
- ✅ 第三人称角色(带发型)在地形上跑、HUD 布局在位、`.msenv` 驱动的天空/光/雾
|
||||||
- ❌ 观感**不等价**:树仍是 proxy(虽已用真实 bark/composite atlas)、无 `.mse` 特效、水面已用真 30 帧序列但深度系数未标定、HUD 已用真实九宫格贴图但布局未按受控帧对齐、光照/色调未对受控帧校准、机位随手放、角色材质 sphere-map 高光管线已端口但默认休眠(无装备层驱动 power)
|
- ❌ 观感**不等价**:树仍是 proxy(虽已用真实 bark/composite atlas)、无 `.mse` 特效、水面已用真 30 帧序列但深度系数未标定、HUD 已用真实九宫格贴图但布局未按受控帧对齐、光照/色调未对受控帧校准、机位随手放、角色材质 sphere-map 高光已由装备层驱动(`equip_model.gd:283-288` 用 `item_proto specular/100`)但只覆盖身体/护甲一个部位、power 未按受控帧标定
|
||||||
|
|
||||||
**粗估**:把下面全部做到「目视等价」,单人 **≈ 3–6 个月**(不含 Phase 2 移动端 bring-up;
|
**粗估**:把下面全部做到「目视等价」,单人 **≈ 3–6 个月**(不含 Phase 2 移动端 bring-up;
|
||||||
需在地形/水面正确性修复和 `.spt` 转换可行性验证后重估)。`.spt` 当前缺少可分发的跨平台 reader;
|
需在地形/水面正确性修复和 `.spt` 转换可行性验证后重估)。`.spt` 当前缺少可分发的跨平台 reader;
|
||||||
@@ -115,7 +115,7 @@ TreeSize/TreeVariance。当前实现已移除这些随机变换;若替代树
|
|||||||
| 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.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.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.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.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` 驱动(✅ 身体/护甲:`project/ui/equip_model.gd:283-288` `_apply_body()`;⬜ 武器 / 发型 / 时装等其余部位未接);受控帧标定 power(⬜,§0 门禁) | M | B9 |
|
||||||
| 2.8 | 角色专属光(`DirectionalLight.Character`) | 只用 `Background` 光 | 角色颜色与原客户端 Character light 接近 | 给 `SRC_MIX`/`SRC_SKIN` 加 character-light uniform,或用 light `cull_mask` 分层 | S–M | E2 |
|
| 2.8 | 角色专属光(`DirectionalLight.Character`) | 只用 `Background` 光 | 角色颜色与原客户端 Character light 接近 | 给 `SRC_MIX`/`SRC_SKIN` 加 character-light uniform,或用 light `cull_mask` 分层 | S–M | 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.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.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 |
|
||||||
@@ -237,7 +237,7 @@ pivot、z-index、九宫格边距、贴图/字体路径、字号、描边、颜
|
|||||||
7. 1.1 `.spt` reader/converter 通过授权与技术验证后批量离线转 Mesh,并接入统一 `TreeDescriptor`
|
7. 1.1 `.spt` reader/converter 通过授权与技术验证后批量离线转 Mesh,并接入统一 `TreeDescriptor`
|
||||||
8. 4.1–4.2 原客户端 30 帧水纹 + 深度 alpha;4.3–4.5 只按受控参考帧选择性增强
|
8. 4.1–4.2 原客户端 30 帧水纹 + 深度 alpha;4.3–4.5 只按受控参考帧选择性增强
|
||||||
9. 5.1 云层 + 1.3 lens flare
|
9. 5.1 云层 + 1.3 lens flare
|
||||||
10. ✅ 2.7 sphere-map 高光(管线已端口,默认休眠);⬜ 2.5 其余固定功能材质分支 / 2.8 角色专属光
|
10. ✅ 2.7 sphere-map 高光(管线已端口 + 装备层已驱动身体部位;逐部位与 power 标定仍缺);⬜ 2.5 其余固定功能材质分支 / 2.8 角色专属光
|
||||||
11. 3.6 instanced grass(仅在取证确认后)
|
11. 3.6 instanced grass(仅在取证确认后)
|
||||||
|
|
||||||
**第 3 梯队(大工程 / 可并行 / 部分可选)**
|
**第 3 梯队(大工程 / 可并行 / 部分可选)**
|
||||||
|
|||||||
+107
-24
@@ -241,10 +241,10 @@ godot --path project --script char_select_visual_test.gd
|
|||||||
| R0-01 | 固定资源/场景基线与区域截图配置 | 可用参考资源 | 已有 A1/C1 实景入口和部分哈希;区域差异待补 |
|
| R0-01 | 固定资源/场景基线与区域截图配置 | 可用参考资源 | 已有 A1/C1 实景入口和部分哈希;区域差异待补 |
|
||||||
| R1-01 | 主角晚到、重复事件、重连回归与修复 | R0 最小基线 | 晚到/重入/重试回归通过;真实重连待验 |
|
| R1-01 | 主角晚到、重复事件、重连回归与修复 | R0 最小基线 | 晚到/重入/重试回归通过;真实重连待验 |
|
||||||
| R1-02 | 地图坐标、高度、相机就绪检查 | R1-01 | A1/C1 晚到定位实景通过;坡地/边界待验 |
|
| R1-02 | 地图坐标、高度、相机就绪检查 | R1-01 | A1/C1 晚到定位实景通过;坡地/边界待验 |
|
||||||
| R2-01 | 同战士 GR2/GLB 实验台及对照报告 | R0 | 待执行 |
|
| R2-01 | 同战士 GR2/GLB 实验台及对照报告 | R0 | 实验台与悬空修复已完成并有数值断言;GLB 对照仍待执行 |
|
||||||
| R2-02 | 发型/身体/附件修复及路线决定 | R2-01 | CPU 绕序、GPU 动态取景和选角朝向已修复;8 个种族 GPU 待机基体矩阵已通过,装备/动作/最终包待验 |
|
| R2-02 | 发型/身体/附件修复及路线决定 | R2-01 | 发型材质根因已修复并有八职业回归;导出包复验待做 |
|
||||||
| R3-01 | 深度/材质/环境与地形校准 | R1、R2 几何稳定 | 材质已有改动,待验证 |
|
| R3-01 | 深度/材质/环境与地形校准 | R1、R2 几何稳定 | 材质已有改动,待验证 |
|
||||||
| R4-01 | 选角、技能栏装配与数据恢复 | R1 | 快捷栏重叠修复及服务器恢复回归通过;真实登录数据待验 |
|
| R4-01 | 选角、技能栏装配与数据恢复 | R1 | 待执行 |
|
||||||
| R5-01 | 包资源清单、最终 `.app` 冒烟与画面报告 | R1–R4 | 待执行 |
|
| R5-01 | 包资源清单、最终 `.app` 冒烟与画面报告 | R1–R4 | 待执行 |
|
||||||
| R6-01 | 性能基线与静态资源缓存试验 | R5 | 后续 |
|
| R6-01 | 性能基线与静态资源缓存试验 | R5 | 后续 |
|
||||||
|
|
||||||
@@ -258,31 +258,114 @@ godot --path project --script char_select_visual_test.gd
|
|||||||
- 发现地图路径原来只在 setup 时按已收到的主角位置选择。新增首次有效主角位置到达后的地图重选,复用已有地图重载和相机/移动同步基点定位函数。
|
- 发现地图路径原来只在 setup 时按已收到的主角位置选择。新增首次有效主角位置到达后的地图重选,复用已有地图重载和相机/移动同步基点定位函数。
|
||||||
- 修改 `ui/player_view.gd`:拒绝非法 race、缺失身体文件及解析失败的源文件。模型节点在入树时才实际加载,因此使用源文件预检,不能把入树前为空的 `get_info()` 当成加载失败。
|
- 修改 `ui/player_view.gd`:拒绝非法 race、缺失身体文件及解析失败的源文件。模型节点在入树时才实际加载,因此使用源文件预检,不能把入树前为空的 `get_info()` 当成加载失败。
|
||||||
- 新增 `main_character_sync_test.gd`:测试失败重试、晚到/无信号实体、重入、重复事件、移动不回滚、过期构建等,已执行得到 `failures=0`。注入失败用例的 warning 属预期。
|
- 新增 `main_character_sync_test.gd`:测试失败重试、晚到/无信号实体、重入、重复事件、移动不回滚、过期构建等,已执行得到 `failures=0`。注入失败用例的 warning 属预期。
|
||||||
|
|
||||||
### 2026-09-08 R2/R4 后续记录
|
|
||||||
|
|
||||||
以下为历史定位过程,早期 C++ 实验加载了旧 debug 扩展,相关排除结论无效;以文末复核记录为准。
|
|
||||||
|
|
||||||
- 确认用户选角用的 `HairIndex 0` 由 `warrior.msm` 精确映射到
|
|
||||||
`hair/hair_1_1.gr2` 与 `warrior_hair_01.dds`;新增解析回归,避免把默认路径或颜色猜测为实际配置。
|
|
||||||
- 选角实景中,`warrior_5_1.GR2`(63 骨)与 `hair_1_1.gr2`(74 骨)组合仍无脸部可见。去除发型后脸部完整,故护甲/脸部几何未丢失;分别使用当前身体蒙皮、强制发型绑定姿势、源发型 alpha 和背面剔除渲染均无差异。没有提交这些无效的猜测性改动。下一步校验角色选择协议的 `hair_part` 语义与标准发型编号,而不是继续调整绕序、动画矩阵或透明参数。
|
|
||||||
- 修复 `root/msm` 覆盖规格缺少 `PathName` 时的解析回退:原逻辑只查职业根目录,漏掉 `hair/` 子目录,导致 `HairIndex 1001..1012` 找不到模型并静默保留 `PlayerView` 默认的 `hair_1_1`。`race_spec_test.gd` 现覆盖真实 1001 解析和文件定位;选角视觉用例确认 1001 实际替换为 `hair_2_1.gr2`。发型前脸遮挡尚未随该修复消失,继续作为几何组合项跟踪。
|
|
||||||
- 移除 `Hud` 中覆盖真实栏位的静态空快捷栏;`Quickbar` 现在有明确的底部锚点尺寸和更高绘制层级。`skill_test.gd` 验证服务端 `type=2` 技能槽恢复后图标可见,且 `race_spec_test.gd`、`skill_test.gd` 已通过。
|
|
||||||
- `gamescene_test.gd` 与 `equip_model_test.gd` 已通过;前者故意使用缺失地图,其预期加载错误不能当成真实地图测试通过。
|
- `gamescene_test.gd` 与 `equip_model_test.gd` 已通过;前者故意使用缺失地图,其预期加载错误不能当成真实地图测试通过。
|
||||||
- 新增 `gamescene_visual_test.gd`:Apple M4 / Metal Forward+ / Godot 4.7.1 上已执行 A1/C1;A1 加载 20 区块/601 物件,C1 加载 20 区块/565 物件,身体加发型共 4 个表面,实体定位断言通过。两者均使用 FakeClient,未连接实际服务器。
|
- 新增 `gamescene_visual_test.gd`:Apple M4 / Metal Forward+ / Godot 4.7.1 上已执行 A1/C1;A1 加载 20 区块/601 物件,C1 加载 20 区块/565 物件,身体加发型共 4 个表面,实体定位断言通过。两者均使用 FakeClient,未连接实际服务器。
|
||||||
- 截图人工观察:地图和真实人物出现;UI 仍有布局/风格问题,不能标为 R3/R4 完成。选角截图仍复现发型遮脸;单独翻转 winding 的试验未解决,未保留新的绕序修改。
|
- 截图人工观察:地图和真实人物出现;UI 仍有布局/风格问题,不能标为 R3/R4 完成。选角截图仍复现发型遮脸;单独翻转 winding 的试验未解决,未保留新的绕序修改。
|
||||||
- 用例命令、输出位置和验证边界见 [test/rendering/README.md](../test/rendering/README.md)。早期失败截图保留在 `build/rendering/`;后续脚本变化应重新执行,不复用旧报告作为新代码证据。
|
- 用例命令、输出位置和验证边界见 [test/rendering/README.md](../test/rendering/README.md)。早期失败截图保留在 `build/rendering/`;后续脚本变化应重新执行,不复用旧报告作为新代码证据。
|
||||||
- 尚未完成:GLB 对照、发型根因修复、完整视觉基线与区域阈值、真实服务器重连、最终 macOS 包验收、R6 优化。下一优先项是发型的身体/附件变换与深度隔离。
|
- 尚未完成:GLB 对照、发型根因修复、完整视觉基线与区域阈值、真实服务器重连、最终 macOS 包验收、R6 优化。下一优先项是发型的身体/附件变换与深度隔离。
|
||||||
|
|
||||||
### 2026-09-08 R2 根因复核与修复
|
### 2026-09-08 第二批实施记录(R2-01 悬空 / R2-02 发型)
|
||||||
|
|
||||||
- 构建复核:`build` 为 Release,编译更新 release dylib,但本地 Godot 加载仍停留在 9 月 7 日的 debug dylib。此前 inverse-bind、alpha 和剔除实验没有加载修改后的扩展,不能用它们排除根因。新增 `bash script/rendering_test.sh`,构建后同步本地加载库、重签名、输出哈希并运行绕序回归。
|
#### R2-01 人物整体悬空
|
||||||
- 冻结动画后改用双面材质显示完整后背,证实原选角相机在人物背后;身体背面被剔除,露出远侧正面内壁,后脑头发遂表现为“盖脸”。`cpu_skin()` 重建身体和发型索引时忽略 `flip_winding`,已对齐静态/GPU 网格的索引顺序;`PlayerView` 设置 PC 绕序,选角 pivot 从 -90° 改为 +90°。
|
|
||||||
- 真实 Metal 选角复核:`main_part=20009`、`hair_part=0` 的脸、肩甲、护腕及裙甲完整,头发位于头顶;保留头发和原透明贴图。颜色/护甲与用户参考资源的进一步一致性仍待校准,不标为 40250 像素验收。
|
- 复现:`gamescene_visual_test.gd`(A1、C1)与 `char_bench_test.gd` 的 feet 机位,角色脚底离地约一个骨盆高;`tools` 下的 foot_probe 预测偏差 0.146 cm 量级。
|
||||||
- 更正 PathName 结论:MSM 中原有 PathName;`RaceSpec` 的空 SpecialPath 错误覆盖父级路径。已修复继承,并去除引号 token 的词法标记。前轮 hair 子目录回退仅缓解此问题。
|
- 确认根因:渲染桥把动画世界姿势按 Granny `GrannyBuildWorldPose` 的 `Offset4x4` 又乘了一次模型导出期的 `InitialPlacement`,而 Metin2 的动画 `.gr2` 根骨轨道本身已含骨盆高度,等于重复计一次(实测 IP.z:武士 99.443、刺客 92.589、修罗 103.674、萨满 95.473,单位 cm)。
|
||||||
- `character_winding_test.gd` 对比绑定姿势及待机 0/0.5/1 秒的逐三角形反向索引,覆盖身体与发型,结果 `failures=0`。`race_spec_test`、`equip_model_test`、主角恢复回归通过;A1 GameScene 重新加载 20 区块/601 物件,主角定位及真实模型断言通过。
|
- 修改文件:`libgr2/include/gr2/gr2.h`、`libgr2/src/gr2_skeleton.cpp`(`sample_pose` 增加可选 `root_offset`,默认仍取 `sk.initial_placement`,`bind_pose` 行为不变,oracle 数值一致性未放宽);`extension/src/metin2_anim.cpp`(渲染路径显式传单位阵)。
|
||||||
- 选角输出现在按独立目录保存 `selection.png`、`report.json` 和发型纹理,报告包含实际 debug dylib、身体/头发/贴图哈希、引擎和取样时间。默认发型 CPU 截图:`build/rendering/selection-1788843070-9087/selection.png`;1001 GPU 用例:`build/rendering/selection-1788843071-9069/selection.png`。
|
- 断言:`gamescene_visual_test.gd` 新增 `feet_gap`(阈值 0.03 m)。A1、C1 实测均为 **0.001373 m**,`passed: true`。
|
||||||
- 未覆盖:全职业、完整动作/换装矩阵、GLB 同资源对照、坡地足底误差、40250 同相机比较、真实联机和最终 macOS 包;R2 整体仍未关闭。
|
- 未覆盖:只验证了 `wait` @0.5 s 与平地。坡地、跳跃/受击等位移较大的动作、以及 NPC/怪物模型未逐一复验。
|
||||||
- GPU 1001 取景根因修复:GPU 顶点着色器变形不会改变 `MeshInstance3D.get_aabb()`,选角此前仍以绑定姿势盒取景,故角色被推到窗口上方。`Metin2Model` 现首帧采样实际蒙皮包围盒用于剔除,并按需提供当前姿势包围盒;`CharSelectScreen` 收到 GPU 就绪通知后用该盒重新取景。`char_select_visual_test.gd` 在冻结 0.5 秒参考姿势后也走同一生产取景路径,并在报告中保留相机与网格包围盒数据。Apple M4 / Metal 的 HairIndex 1001 GPU 截图 [selection-1788844385-11872](../build/rendering/selection-1788844385-11872/selection.png) 人工审阅通过,脸部、发型、盔甲与完整人物均在画面内;对应 debug dylib SHA-256 为 `a268eef3f5cd1fc263652c4e0a9c3693f33f359b3706aa6850525c8e52108b39`。
|
|
||||||
- GPU 路径仍保持环境变量启用的实验状态;1001 发型和带装备场景当前只覆盖男战士。完整职业的装备、动作、地图与性能测试仍按 R2/R6 的未完成项推进。
|
#### R2-02 发型发白、看起来「挡脸」
|
||||||
- 追加 GPU 基体矩阵:使用 `--job=0..7 --main-part=0` 在同一 Apple M4 / Metal 环境生成八种种族的选择截图;全部装配断言通过,人工审阅的 [八种族联系图](../build/rendering/r2-matrix-gpu-contact-sheet.png) 中人物、脸部和默认发型均完整入框。此覆盖仅是无装备的待机选择画面,不替代每职业装备、动作或 40250 参考对比。
|
|
||||||
|
- 复现:`char_bench_test.gd` 的 `face_side` 近景,额发和鬓角出现成片浅奶油色三角面。
|
||||||
|
- 排除项(都不是原因):几何与挂接(发型权重 100% 在 `Bip01 Head`,骨骼按名重映射 74/74,蒙皮包围盒落在头部 z 149–178,`bind_pose_self_check = 7.6e-05`);贴图(`hair/hair_1_1.dds` 就是 gr2 材质里写的那张,256×256 DXT1,最亮纹素 RGB(90,81,90),**0 个透明纹素**,所以 alpha scissor 是空操作,`dxt.cpp` 无关);UV(全在 [0,1])。
|
||||||
|
- 材质二分(同一机位逐项改一个开关):`TRANSPARENCY_DISABLED` 仍有、`CULL_BACK` 更糟、`metallic_specular = 0` 仍有、**`roughness = 0` 干净**、**`SHADING_MODE_UNSHADED` 干净**。
|
||||||
|
- 确认根因:`Metin2Model::_load_hair()` 是全角色唯一没走 `mtgodot::make_material` 的表面,用的是裸 `StandardMaterial3D` 且 `roughness = 1.0`,浅色斑是 Godot PBR 的高光响应,与身体/武器用的前向材质(shader 带 `specular_disabled`)光照模型不一致。
|
||||||
|
- 修改文件:`extension/src/metin2_model.cpp` 的 `_load_hair()` —— 改用 `mtgodot::MaterialDesc{blend = AlphaTest, alpha_scissor = 0.5, two_sided = true, spec_power = 0}` 走 `make_material`;无贴图时用 `modulate` 保留原来的深棕兜底色。GPU 蒙皮路径本来就已经走 `make_material`,此改动使 CPU 路径与之一致。
|
||||||
|
- 资源哈希(sha256):`PC/ymir work/pc/warrior/warrior_novice.gr2` `7ac881b0c60ced40c9388d6186bdf02a353a58382d12804345cb65fc3dc1fa74`;`PC/ymir work/pc/warrior/hair/hair_1_1.gr2` `2e3c00bda5397d579a2399cfb38a69974667e4f7b92dca6b2a709058a37d0a84`;`PC/ymir work/pc/warrior/general/wait.msa` `7bc14bc29a63a78708b9a76618aee9229286f255e725dd669c60ef838cec6a15`。其余七个职业的身体/发型哈希记在各次 `report.json` 里。
|
||||||
|
- 执行命令与结果:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./build.sh Debug
|
||||||
|
godot --headless --path project --script main_character_sync_test.gd # failures=0
|
||||||
|
godot --headless --path project --script gamescene_test.gd # PASS
|
||||||
|
godot --headless --path project --script equip_model_test.gd # PASS
|
||||||
|
godot --path project --script char_select_visual_test.gd # PASS
|
||||||
|
godot --path project --script gamescene_visual_test.gd # passed:true, feet_gap 0.001373
|
||||||
|
godot --path project --script gamescene_visual_test.gd -- --c1 # passed:true, feet_gap 0.001373
|
||||||
|
godot --path project --script char_bench_test.gd -- --race {0,1,2,3} # 四职业近景人工复核,浅色斑消失
|
||||||
|
godot --path project --script model_render_test.gd # PASS(八职业)
|
||||||
|
```
|
||||||
|
|
||||||
|
- 未覆盖:只验证了基础 `*_novice` 身体加 `hair_1_1`。5th 护甲等换装组合、`hair_skin` 覆写路径、GPU 蒙皮路径的实机画面未逐一复验;**导出包内复验尚未完成**(见下)。
|
||||||
|
|
||||||
|
#### 新增:不启动游戏的模型渲染自动回归
|
||||||
|
|
||||||
|
- 新增 `project/model_render_test.gd` 与基线 `test/rendering/model_baseline.json`。离线装配单角色(无地图、无网络、无服务器),固定灯光与机位,对像素算指标并断言,退出码即结论。
|
||||||
|
- 指标与阈值、为什么用平均亮度而不是「炸白像素占比」、以及覆盖边界,见 [test/rendering/README.md](../test/rendering/README.md)。
|
||||||
|
- **有效性已验证**:把 `_load_hair()` 临时改回 `StandardMaterial3D(roughness = 1)` 重新编译后,八个职业全部失败(`hair_mean_lum` 偏离基线 0.0109…0.0371,阈值 0.006);改回 `make_material` 后 `PASS`。这条不是「截图已生成」式的通过。
|
||||||
|
- 局限:基线绑定当前引擎版本、灯光和资源。改模型/发型材质/灯光都会使其失效,必须人工看过渲染结果再 `--bless` 并在此记录原因。
|
||||||
|
|
||||||
|
#### 纠正前一批记录的一处误判
|
||||||
|
|
||||||
|
- 第一批记录里「选角截图仍复现发型遮脸」中被一并当作缺陷的「武器平躺在地上」不成立:那是选角页 `ui/char_select_screen.gd:517` 建的 `dragon_left/right_button_0{1,2,3}.sub` 两个 2D 导航按钮(`(714,712)` / `(1024,712)`,160×44),不是武器挂点问题。武器挂接没有发现缺陷。
|
||||||
|
|
||||||
|
#### 仍未完成
|
||||||
|
|
||||||
|
- ~~**导出包复验**:包内没有离线渲染入口(导出模板不接受 `--script`)~~ **已解决(2026-09-08)**:新增
|
||||||
|
`project/package_render_test.gd`,由 `client_main.gd` 在 `MT_TEST_MODE=render` 时挂到主场景下——
|
||||||
|
绕开导出模板不收 `--script` 的限制,走真实 `GameScene` + `Metin2World` + `PlayerView`,不连服务器,
|
||||||
|
报告和截图落到 `user://render-check/`(`MT_RENDER_OUTPUT` 可改)。
|
||||||
|
|
||||||
|
```
|
||||||
|
MT_TEST_MODE=render build/export/mtgodot-poc.app/Contents/MacOS/mtgodot-poc
|
||||||
|
```
|
||||||
|
|
||||||
|
首次在**导出包内**跑的结果(2026-09-08,release 包):`PASS` —— 扩展三个类都注册、资源根解析到
|
||||||
|
`Contents/Resources/assets`、地图 20/20 chunk、`model_built=true`、`surfaces=4`、`feet_gap=0.001373`,
|
||||||
|
截图是正常的草地 + 角色。R2-01/R2-02 的「导出包中复验」这一步由此补齐。
|
||||||
|
未覆盖:网络驱动的进场时序、40250 像素比对、性能。
|
||||||
|
|
||||||
|
- ~~**新发现的实机故障**:进入游戏后 3D 画面整片灰紫、主角仍是蓝色胶囊,2D UI 与小地图正常~~
|
||||||
|
**已修复(2026-09-08)**。
|
||||||
|
|
||||||
|
**根因**:`EntityStore::mut_despawn()` 在 `GC_CHARACTER_DEL` 命中主角 vid 时把 `m_main_vid` 清零了。
|
||||||
|
40250 服务器在进场那一两秒里会对**主角自己的 vid** 连发 `GC_CHARACTER_DEL` + `GC_CHARACTER_ADD`
|
||||||
|
(视野重算,实测 10 次左右),于是主角被永久「取消主角身份」:`get_main_vid()` 返回 0 →
|
||||||
|
`_sync_main_character()` 直接 return → 模型装配代码一次都没跑(`main_attempts=0`),
|
||||||
|
占位胶囊留在 y=0,相机跟着它钻到地形底下 → 满屏灰紫。地图其实一直是好的。
|
||||||
|
|
||||||
|
**参考端怎么做**(`40250/ClientVS22/source/UserInterface/`):主角 vid 归
|
||||||
|
`CNetworkActorManager::m_dwMainVID` 所有,只由 `SetMainActorVID()`(GC_MAIN_CHARACTER)写、
|
||||||
|
只在管理器销毁时清;`RemoveActor()`(NetworkActorManager.cpp:440)删 actor 行和角色实例,
|
||||||
|
**不碰 m_dwMainVID**。重新 add 时 `__AppendCharacterManagerActor()` 用
|
||||||
|
`kCreateData.m_isMain = __IsMainActorVID(dwVID)` 把它重新认成主角,并再跑一次
|
||||||
|
`SetMainCharacterIndex(dwVID)`。
|
||||||
|
|
||||||
|
**修法**(`extension/src/net/entity_store.cpp`,两处对齐参考端):
|
||||||
|
1. `mut_despawn()` 不再清 `m_main_vid`(m2dev 分支的 `GC_CHARACTER_DEL` 同样处理);
|
||||||
|
该字段现在只由 `mut_spawn_main()` 写、只由 `reset_for_map_change()` 清(对应参考端的销毁)。
|
||||||
|
2. `mut_spawn_full()` 里 `vid == m_main_vid` 就把 `is_main` 置回 true,并补一条 `MainSet`
|
||||||
|
变更,对应 `__AppendCharacterManagerActor()` 的 `m_isMain` + `SetMainCharacterIndex`。
|
||||||
|
|
||||||
|
**取证手段**(保留在树里):
|
||||||
|
- `project/net_trace.gd`(由 `client_main.gd` 挂在 AppFlow 之后)每帧比对 `get_main_vid()`,
|
||||||
|
丢主角时打 `NETTRACE: MAIN_LOST ... reason=despawn|silent` 和最近 24 条事件环。
|
||||||
|
`reason` 直接区分「`GC_CHARACTER_DEL` 命中主角」和「`reset_for_map_change()` 静默清零」
|
||||||
|
(后者会清空 `m_changes`,不发 despawn 信号)。`MT_NETTRACE=1` 打全量。
|
||||||
|
- `game_scene.gd` `_diag_tick()`:进游戏若干秒后,`_model_built` 或 `_map_loaded()` 任一为假就打一行
|
||||||
|
`GAMESCENE_DIAG:`(`race` / `pos_cm` / `map_path` / `map_base_set` / `main_attempts` /
|
||||||
|
`main_sync_ready` / `map_report` …)。`MT_DIAG=1` 强制输出并连打 6 次(间隔 2 s),
|
||||||
|
`MT_DIAG_AFTER_MS` 改延时,`MT_DIAG_SHOT=<png>` 在最后一次存一张实机帧。
|
||||||
|
|
||||||
|
**实机复验**(真服 192.168.21.203,classic,2026-09-08):
|
||||||
|
- 修前 `./script/live_smoke_test.sh --no-build --no-mutations --no-reconnect` →
|
||||||
|
`FAIL: 主角色 VID 有效` / `FAIL: 主角色不存在`;`NETTRACE: MAIN_LOST was=25910 now=0 reason=despawn`。
|
||||||
|
- 修后同一命令 → `LIVE_SMOKE RESULT: PASS`,`main_vid=25933 pos_cm=(963444.0, 266221.0, 0.0) entities=19`。
|
||||||
|
- 40 秒实机连打诊断:`model_built=true`、`main_attempts=1`、`player_pos=(418.44, 177.67, 614.21)`
|
||||||
|
(站在 C1 地形上,不再是 y=0),16 次采样全程稳定;`MT_DIAG_SHOT` 存下的帧是正常的
|
||||||
|
地形 + 建筑 + NPC + 主角模型 + HUD + 小地图。
|
||||||
|
|
||||||
|
未覆盖:主角材质在俯视机位下偏暗的观感问题——那属于光照/色调,受 PARITY-GAP §0 门禁约束,
|
||||||
|
不在这次修复范围内。
|
||||||
|
|
||||||
|
- R2-01 的 GLB 对照、R0 完整视觉基线与区域阈值、R4 选角/快捷栏、R5 最终包验收、R6 性能,均未开始。
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# 神兽国 A1 场景最终渲染开发文档
|
# 神兽国 A1 场景最终渲染开发文档
|
||||||
|
|
||||||
> 状态:实施规格(Implementation Specification)
|
> 状态:实施规格(Implementation Specification)
|
||||||
> 目标工程:`mtgodot-poc`
|
> 目标工程:`metin2-client`
|
||||||
> 资源来源:仓库内 `assets/`(gitignored,`MT_ASSETS` 可覆盖)
|
> 资源来源:仓库内 `assets/`(gitignored,`MT_ASSETS` 可覆盖)
|
||||||
> 原客户端行为参考:同级仓库 `m2dev-client-src-main/src`
|
> 原客户端行为参考:同级仓库 `m2dev-client-src-main/src`
|
||||||
> 目标参考图:`ingame-shinsoo.png`,1920×1080 的神兽国村庄第三人称画面
|
> 目标参考图:`ingame-shinsoo.png`,1920×1080 的神兽国村庄第三人称画面
|
||||||
|
|||||||
+41
-11
@@ -1,22 +1,25 @@
|
|||||||
# Third-party native dependencies
|
# Third-party native dependencies
|
||||||
|
|
||||||
The GDExtension links three native libraries that are **not** part of Godot or
|
The GDExtension links four native libraries that are **not** part of Godot or
|
||||||
godot-cpp. Phase 1 (macOS) pulled them from Homebrew; that stops working the
|
godot-cpp. Phase 1 (macOS) pulled the first three from Homebrew; that stops
|
||||||
moment we cross-compile for the Android NDK or the iOS SDK (BACKLOG F1/F2), so as
|
working the moment we cross-compile for the Android NDK or the iOS SDK (BACKLOG
|
||||||
of 2026-08-30 all three are built from source as part of our own CMake build.
|
F1/F2), so as of 2026-08-30 they are built from source as part of our own CMake
|
||||||
|
build. Crypto++ joined later (2026-09-02) with the `MT_PROTOCOL=classic` 40250
|
||||||
|
backend and was vendored the same way from the start.
|
||||||
|
|
||||||
Everything lives under `extension/third_party/` and is wired by
|
Everything lives under `extension/third_party/` and is wired by
|
||||||
`extension/third_party/CMakeLists.txt`, which exposes three aliases:
|
`extension/third_party/CMakeLists.txt`, which exposes four aliases:
|
||||||
|
|
||||||
| Alias | Backing target | Consumed by |
|
| Alias | Backing target | Consumed by |
|
||||||
|-----------------|------------------|------------------------|
|
|-------------------|------------------|------------------------------|
|
||||||
| `mt3p::sodium` | `sodium` | `mtnet`, `mtpack`, `mtproto` |
|
| `mt3p::sodium` | `sodium` | `mtnet`, `mtpack`, `mtproto` |
|
||||||
| `mt3p::zstd` | `libzstd_static` | `mtpack` |
|
| `mt3p::zstd` | `libzstd_static` | `mtpack` |
|
||||||
| `mt3p::minilzo` | `minilzo` | `mtproto` |
|
| `mt3p::minilzo` | `minilzo` | `mtproto` |
|
||||||
|
| `mt3p::cryptopp` | `cryptopp` | `mtnet` (classic backend) |
|
||||||
|
|
||||||
No source file changed: `#include <sodium.h>`, `#include <zstd.h>` and
|
No source file changed: `#include <sodium.h>`, `#include <zstd.h>`,
|
||||||
`#include <lzo/lzo1x.h>` all still resolve (the last via a 1-line shim header,
|
`#include <lzo/lzo1x.h>` and `#include <cryptopp/…>` all still resolve (the LZO
|
||||||
see below).
|
one via a 1-line shim header, see below).
|
||||||
|
|
||||||
## libsodium — via `robinlinden/libsodium-cmake`
|
## libsodium — via `robinlinden/libsodium-cmake`
|
||||||
|
|
||||||
@@ -60,11 +63,38 @@ see below).
|
|||||||
or commercial use. (item_proto/mob_proto are the only LZO consumers; a
|
or commercial use. (item_proto/mob_proto are the only LZO consumers; a
|
||||||
clean-room LZO1X decompressor is the eventual fix.)
|
clean-room LZO1X decompressor is the eventual fix.)
|
||||||
|
|
||||||
|
## Crypto++ — vendored source (not a submodule)
|
||||||
|
|
||||||
|
* **Files:** `extension/third_party/cryptopp/` (~6.6 MB), copied from the 40250
|
||||||
|
client tree (`ClientVS22/extern/include/cryptopp`), `CRYPTOPP_VERSION 840` =
|
||||||
|
**8.4.0**. The 40250 server links this exact version
|
||||||
|
(`Server/.../extern/cryptopp_8_4_0.tar.gz`), so the version is pinned by
|
||||||
|
wire compatibility, not preference.
|
||||||
|
* **Why:** the `MT_PROTOCOL=classic` backend's `_IMPROVED_PACKET_ENCRYPTION_`
|
||||||
|
cipher — DH2 key agreement + hint-selected block ciphers (Twofish default) in
|
||||||
|
CTR mode. Only consumer is `extension/src/net/classic/classic_cipher.{h,cpp}`.
|
||||||
|
The m2dev backend does not use it (that one is libsodium/XChaCha20).
|
||||||
|
* **No upstream CMakeLists**, so `cryptopp/CMakeLists.txt` is ours: it globs the
|
||||||
|
library TUs and drops the cryptest / benchmark / adhoc / FIPS-selftest ones
|
||||||
|
(`adhoc`, `bench1..3`, `datatest`, `dlltest`, `fipsalgt`, `fipstest`, `pch`,
|
||||||
|
`regtest1..4`, `test`, `validat0..10`).
|
||||||
|
* **ASM disabled everywhere** (`CRYPTOPP_DISABLE_ASM` / `_SSSE3` / `_AESNI`) so
|
||||||
|
one pure-C++ build works on macOS, the Android NDK and the iOS SDK; the classic
|
||||||
|
cipher needs no AES-NI / SHA-ext. Android additionally compiles the NDK's
|
||||||
|
`sources/android/cpufeatures/cpu-features.c` in, because upstream `cpu.cpp`
|
||||||
|
includes `cpu-features.h` unconditionally under `__ANDROID__`.
|
||||||
|
* Built with `-w` (upstream is not warning-clean under `-Wall`) and
|
||||||
|
`-fno-strict-aliasing`.
|
||||||
|
* **License:** **public domain** — the compilation carries Wei Dai's copyright,
|
||||||
|
but every individual file is placed in the public domain by its authors
|
||||||
|
(`extension/third_party/cryptopp/License.txt`). Ship-safe; unlike miniLZO it
|
||||||
|
imposes no obligation before a public or commercial release.
|
||||||
|
|
||||||
## Rebuilding / fetching
|
## Rebuilding / fetching
|
||||||
|
|
||||||
`./build.sh` auto-runs `git submodule update --init --recursive` if any of the
|
`./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
|
three submodules (godot-cpp, zstd, libsodium-cmake+libsodium) is missing.
|
||||||
fetch.
|
miniLZO and Crypto++ are vendored source and need no fetch.
|
||||||
|
|
||||||
Homebrew `libsodium` / `zstd` / `lzo` are no longer referenced by the build and
|
Homebrew `libsodium` / `zstd` / `lzo` are no longer referenced by the build and
|
||||||
can be uninstalled.
|
can be uninstalled.
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ PoC 的价值是**降低不确定性**,go 和 no-go 都是有效产出。
|
|||||||
> 剩余是真机验证(性能 / 生命周期 / mediump)+ Android 端。
|
> 剩余是真机验证(性能 / 生命周期 / mediump)+ Android 端。
|
||||||
> **画质改进 pass(档 1 + 档 2)已完成**:MSAA x4 / mip 链 / sRGB 线性着色 / 半球环境 + 3 方向光 / 双面;
|
> **画质改进 pass(档 1 + 档 2)已完成**:MSAA x4 / mip 链 / sRGB 线性着色 / 半球环境 + 3 方向光 / 双面;
|
||||||
> libgr2 加 `granny_material` 链解析 → 逐 tri_group 正确贴图(原来整模型套一张瞎猜的 dds)+ alpha-test 镂空。
|
> libgr2 加 `granny_material` 链解析 → 逐 tri_group 正确贴图(原来整模型套一张瞎猜的 dds)+ alpha-test 镂空。
|
||||||
> 无回归(gr2fuzz 9166 · render_fuzz 9166 · oracle 23/23)。细节见 `docs/steps/M1-static-render.md`「画质改进 pass」。
|
> 无回归(gr2fuzz 9166 · render_fuzz 9166 · oracle 23/23)。细节见 `docs/reference/steps/M1-static-render.md`「画质改进 pass」。
|
||||||
|
|
||||||
- **里程碑 M3 全部门禁通过** = "C++ + bgfx、复用 EterGrnLib 逻辑" 的跨平台方案可行,进入正式移植规划。
|
- **里程碑 M3 全部门禁通过** = "C++ + bgfx、复用 EterGrnLib 逻辑" 的跨平台方案可行,进入正式移植规划。
|
||||||
- **门禁失败要区分两类**:
|
- **门禁失败要区分两类**:
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ still authoritative:
|
|||||||
**Relative paths inside these files** (`../libgr2`, `oracle/`, `test/golden/…`,
|
**Relative paths inside these files** (`../libgr2`, `oracle/`, `test/golden/…`,
|
||||||
`engine/…`, `app/…`) refer to the old xrender-poc layout. The mapping:
|
`engine/…`, `app/…`) refer to the old xrender-poc layout. The mapping:
|
||||||
|
|
||||||
| old (xrender-poc) | now (mtgodot-poc) |
|
| old (xrender-poc) | now (metin2-client; the repo was created as `mtgodot-poc` and renamed later — the `mtgodot-poc.app` / `.apk` artifact names and the `org.internal.mtgodotpoc` bundle id did **not** change) |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `libgr2/` | `libgr2/` (vendored, unchanged) |
|
| `libgr2/` | `libgr2/` (vendored, unchanged) |
|
||||||
| `formats/` | `formats/` (vendored) |
|
| `formats/` | `formats/` (vendored) |
|
||||||
|
|||||||
@@ -216,7 +216,7 @@ T4,T5,T6(+可选 T7b) ──▶ T8(gr2dump)
|
|||||||
- **主**:`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`)。
|
- **主**:`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-3;4 个 `>=1e-1` **经 oracle 证实 libgr2 正确**(assassin 对 Granny 3.1e-5),是自洽不变量本身对这几个双-root 资产不成立,非 libgr2 bug。
|
- **自洽**:T5 bind pose 自洽 —— ✅ `warrior_cheongrin` `6.1e-5` PASS。全量 2013/2017 < 1e-3;4 个 `>=1e-1` **经 oracle 证实 libgr2 正确**(assassin 对 Granny 3.1e-5),是自洽不变量本身对这几个双-root 资产不成立,非 libgr2 bug。
|
||||||
- **fuzz**:T9b 全量 9166 —— ✅ **零崩溃 + 零谓词失败**。
|
- **fuzz**:T9b 全量 9166 —— ✅ **零崩溃 + 零谓词失败**。
|
||||||
- **变体可实现**:✅ 无 BitKnit / 无 Oodle0;曲线全 `OldCurveType`,degree ≤ 2,T7b 全覆盖。不做的变体见 [`../../libgr2/README.md`](../../libgr2/README.md)「明确不做」(均 0 出现,坏输入干净报错不崩)。
|
- **变体可实现**:✅ 无 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/`。
|
- **产出**:✅ `test/{fuzz-report,m2-numeric,noise_floor}.json` + `test/assets.list` + `test/oracle_dumps/`。
|
||||||
- **noise_floor**:✅ `test/noise_floor.json`。同-DLL 确定性 = 0(byte-identical);跨实现地板 = mat 4.6e-5 / vtx 6.5e-5;跨版本一档待另一个 granny2.dll。
|
- **noise_floor**:✅ `test/noise_floor.json`。同-DLL 确定性 = 0(byte-identical);跨实现地板 = mat 4.6e-5 / vtx 6.5e-5;跨版本一档待另一个 granny2.dll。
|
||||||
|
|
||||||
|
|||||||
@@ -415,7 +415,20 @@ void Metin2AnimPlayer::apply_pose(double t) {
|
|||||||
tt = std::fmax(0.0, std::fmin(t, duration));
|
tt = std::fmax(0.0, std::fmin(t, duration));
|
||||||
}
|
}
|
||||||
|
|
||||||
gr2::sample_pose(*sk, an, (float)tt, world_buf, skin_buf);
|
// Offset4x4 = identity, NOT the model's exported InitialPlacement.
|
||||||
|
//
|
||||||
|
// gr2::sample_pose defaults to sk.initial_placement because that is what the
|
||||||
|
// oracle passes (GrannyGetModelInitialPlacement4x4) and what the bind pose /
|
||||||
|
// InverseWorldTransform pair is self-consistent with. Metin2's animation .gr2
|
||||||
|
// carries the root bone's pelvis height inside its own track, so composing it
|
||||||
|
// onto InitialPlacement counts that placement twice and lifts the whole actor
|
||||||
|
// by one pelvis height (warrior 99.4 cm, assassin 92.6, sura 103.7, shaman
|
||||||
|
// 95.5 — exactly each model's IP.z). That was the "人物悬空" report.
|
||||||
|
// Measured with tools/rendering foot probe: with identity the lowest skinned
|
||||||
|
// vertex sits within a few mm of z=0 for wait/walk on all four classes, and
|
||||||
|
// only leaves the ground during run's airborne frames.
|
||||||
|
static const gr2::Mat4 kNoOffset{ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 };
|
||||||
|
gr2::sample_pose(*sk, an, (float)tt, world_buf, skin_buf, &kNoOffset);
|
||||||
|
|
||||||
// Crossfade: blend the frozen outgoing-clip pose into this one, then rebuild
|
// Crossfade: blend the frozen outgoing-clip pose into this one, then rebuild
|
||||||
// skin_buf = invWorld · world_blended (PARITY §2.9).
|
// skin_buf = invWorld · world_blended (PARITY §2.9).
|
||||||
@@ -423,7 +436,7 @@ void Metin2AnimPlayer::apply_pose(double t) {
|
|||||||
const gr2::FileInfo &pfi = prev_anim_file->file_info();
|
const gr2::FileInfo &pfi = prev_anim_file->file_info();
|
||||||
if (!pfi.animations.empty()) {
|
if (!pfi.animations.empty()) {
|
||||||
gr2::sample_pose(*sk, pfi.animations[0], (float)prev_anim_time, prev_world_buf,
|
gr2::sample_pose(*sk, pfi.animations[0], (float)prev_anim_time, prev_world_buf,
|
||||||
prev_skin_buf);
|
prev_skin_buf, &kNoOffset);
|
||||||
double w = blend_elapsed / blend_time;
|
double w = blend_elapsed / blend_time;
|
||||||
// ease-in on the incoming clip, matching the client's
|
// ease-in on the incoming clip, matching the client's
|
||||||
// GrannySetControlEaseInCurve(t0,t1, 0,0,1,1) Hermite (p0=0,m0=0,
|
// GrannySetControlEaseInCurve(t0,t1, 0,0,1,1) Hermite (p0=0,m0=0,
|
||||||
|
|||||||
@@ -564,12 +564,6 @@ void Metin2Model::_load_hair() {
|
|||||||
hair_parts = mtgodot::build_parts(hfi);
|
hair_parts = mtgodot::build_parts(hfi);
|
||||||
|
|
||||||
// hair 贴图:同名 .dds,alpha-scissor 镂空
|
// hair 贴图:同名 .dds,alpha-scissor 镂空
|
||||||
Ref<StandardMaterial3D> hm;
|
|
||||||
hm.instantiate();
|
|
||||||
hm->set_roughness(1.0f);
|
|
||||||
hm->set_transparency(StandardMaterial3D::TRANSPARENCY_ALPHA_SCISSOR);
|
|
||||||
hm->set_alpha_scissor_threshold(0.5f);
|
|
||||||
hm->set_cull_mode(StandardMaterial3D::CULL_DISABLED);
|
|
||||||
// SourceSkin (gr2 sibling .dds) -> TargetSkin override (§2.4)
|
// SourceSkin (gr2 sibling .dds) -> TargetSkin override (§2.4)
|
||||||
String hdds = hair_skin.is_empty() ? (hair_gr2.get_basename() + ".dds") : hair_skin;
|
String hdds = hair_skin.is_empty() ? (hair_gr2.get_basename() + ".dds") : hair_skin;
|
||||||
Ref<ImageTexture> ht = _load_dds(hdds);
|
Ref<ImageTexture> ht = _load_dds(hdds);
|
||||||
@@ -577,10 +571,20 @@ void Metin2Model::_load_hair() {
|
|||||||
UtilityFunctions::push_warning(String("[Metin2Model] hair_skin not found: ") + hair_skin);
|
UtilityFunctions::push_warning(String("[Metin2Model] hair_skin not found: ") + hair_skin);
|
||||||
ht = _load_dds(hair_gr2.get_basename() + ".dds"); // fall back to SourceSkin
|
ht = _load_dds(hair_gr2.get_basename() + ".dds"); // fall back to SourceSkin
|
||||||
}
|
}
|
||||||
if (ht.is_valid())
|
// R2-02: the same forward material the body/weapon surfaces use, not a
|
||||||
hm->set_texture(BaseMaterial3D::TEXTURE_ALBEDO, ht);
|
// StandardMaterial3D. The old one ran Godot's PBR specular at roughness 1.0
|
||||||
else
|
// and washed the fringe out into flat cream patches (bisection: roughness 0
|
||||||
hm->set_albedo(Color(0.18f, 0.12f, 0.08f));
|
// or unshaded clean, alpha/cull/metallic changes not). make_material's
|
||||||
|
// shaders are `specular_disabled`, so the hair now lights like the body.
|
||||||
|
// CPU path: skinning already happened on the CPU, so skinned = false.
|
||||||
|
mtgodot::MaterialDesc hd;
|
||||||
|
hd.albedo = ht;
|
||||||
|
hd.blend = mtgodot::BlendMode::AlphaTest; // discard < 0.5, still depth-opaque
|
||||||
|
hd.alpha_scissor = 0.5f;
|
||||||
|
hd.two_sided = true; // hair cards are single-sided geometry
|
||||||
|
Ref<ShaderMaterial> hm = mtgodot::make_material(hd);
|
||||||
|
if (ht.is_null()) // keep the untextured fallback colour (make_material's is pale grey)
|
||||||
|
hm->set_shader_parameter("modulate", Color(0.18f, 0.12f, 0.08f, 1.0f));
|
||||||
hair_mat = hm;
|
hair_mat = hm;
|
||||||
hair_tex = ht; // reused by the GPU-skin path (§2.2)
|
hair_tex = ht; // reused by the GPU-skin path (§2.2)
|
||||||
|
|
||||||
|
|||||||
@@ -119,11 +119,24 @@ void EntityStore::mut_spawn_full(const Entity &src) {
|
|||||||
// caller ever passes `store.get(vid)` back in.
|
// caller ever passes `store.get(vid)` back in.
|
||||||
Entity e = src;
|
Entity e = src;
|
||||||
e.vid = src.vid;
|
e.vid = src.vid;
|
||||||
m_ents[src.vid] = e;
|
// Reference CNetworkActorManager::__AppendCharacterManagerActor():
|
||||||
if (e.is_main) {
|
// kCreateData.m_isMain = __IsMainActorVID(dwVID);
|
||||||
m_main_vid = src.vid;
|
// The main VID is owned by the manager, not by the actor row, so an actor
|
||||||
|
// re-added under the main VID becomes the main instance again. The 40250
|
||||||
|
// server does exactly this at spawn-in: GC_CHARACTER_DEL + GC_CHARACTER_ADD
|
||||||
|
// on our own VID moments after GC_MAIN_CHARACTER. Without this, the re-added
|
||||||
|
// row is a plain PC and the local player stays the fallback capsule.
|
||||||
|
if (e.vid == m_main_vid) {
|
||||||
|
e.is_main = true;
|
||||||
}
|
}
|
||||||
|
m_ents[src.vid] = e;
|
||||||
m_changes.push_back({ChangeKind::Spawn, src.vid});
|
m_changes.push_back({ChangeKind::Spawn, src.vid});
|
||||||
|
if (e.is_main) {
|
||||||
|
// __AppendCharacterManagerActor() re-runs SetMainCharacterIndex(dwVID)
|
||||||
|
// on every main re-add, so re-announce it here too.
|
||||||
|
m_main_vid = src.vid;
|
||||||
|
m_changes.push_back({ChangeKind::MainSet, src.vid});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void EntityStore::mut_ownership(uint32_t vid, uint32_t owner_vid) {
|
void EntityStore::mut_ownership(uint32_t vid, uint32_t owner_vid) {
|
||||||
@@ -170,12 +183,14 @@ void EntityStore::mut_map_bgm(const std::string &name, float volume) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void EntityStore::mut_despawn(uint32_t vid) {
|
void EntityStore::mut_despawn(uint32_t vid) {
|
||||||
|
// Reference CNetworkActorManager::RemoveActor() drops the actor row and the
|
||||||
|
// character instance but never touches m_dwMainVID — that field is written
|
||||||
|
// only by SetMainActorVID() (GC_MAIN_CHARACTER) and cleared only when the
|
||||||
|
// manager is destroyed (our reset_for_map_change()). Clearing it here made a
|
||||||
|
// routine GC_CHARACTER_DEL on our own VID permanently un-main the player.
|
||||||
if (m_ents.erase(vid)) {
|
if (m_ents.erase(vid)) {
|
||||||
m_changes.push_back({ChangeKind::Despawn, vid});
|
m_changes.push_back({ChangeKind::Despawn, vid});
|
||||||
}
|
}
|
||||||
if (vid == m_main_vid) {
|
|
||||||
m_main_vid = 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// CLIENT-GAP §3.2: GC_MOVE no longer mutates position directly. It appends a
|
// CLIENT-GAP §3.2: GC_MOVE no longer mutates position directly. It appends a
|
||||||
@@ -1658,12 +1673,11 @@ void EntityStore::apply(uint16_t header, const void *body, uint16_t len) {
|
|||||||
}
|
}
|
||||||
GCCharacterDel p;
|
GCCharacterDel p;
|
||||||
std::memcpy(&p, body, sizeof(p));
|
std::memcpy(&p, body, sizeof(p));
|
||||||
|
// Same rule as mut_despawn(): the main VID survives a
|
||||||
|
// GC_CHARACTER_DEL and is re-bound by the matching re-add.
|
||||||
if (m_ents.erase(p.vid)) {
|
if (m_ents.erase(p.vid)) {
|
||||||
m_changes.push_back({ChangeKind::Despawn, p.vid});
|
m_changes.push_back({ChangeKind::Despawn, p.vid});
|
||||||
}
|
}
|
||||||
if (p.vid == m_main_vid) {
|
|
||||||
m_main_vid = 0;
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
case GC_MOVE: {
|
case GC_MOVE: {
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
# libgr2
|
# libgr2
|
||||||
|
|
||||||
自研 gr2 v6/v7 **只读**读取器。零第三方依赖。施工文档 [`../docs/steps/M0-gr2-reader.md`](../docs/steps/M0-gr2-reader.md)。
|
自研 gr2 v6/v7 **只读**读取器。零第三方依赖。施工文档 [`../docs/reference/steps/M0-gr2-reader.md`](../docs/reference/steps/M0-gr2-reader.md)。
|
||||||
|
|
||||||
## 能做什么
|
## 能做什么
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// libgr2 —— gr2 v6 只读读取器(demo 子集)。
|
// libgr2 —— gr2 v6 只读读取器(demo 子集)。
|
||||||
// 施工文档:docs/steps/M0-gr2-reader.md
|
// 施工文档:docs/reference/steps/M0-gr2-reader.md
|
||||||
#pragma once
|
#pragma once
|
||||||
#include "types.h"
|
#include "types.h"
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
@@ -83,11 +83,20 @@ private:
|
|||||||
|
|
||||||
// ── M2:姿势采样(model 的 skeleton + anim 的 tracks 可来自不同 gr2)──────
|
// ── M2:姿势采样(model 的 skeleton + anim 的 tracks 可来自不同 gr2)──────
|
||||||
// Granny 语义、行主序、平移在 elem 12..14:
|
// Granny 语义、行主序、平移在 elem 12..14:
|
||||||
// world[i] = Composite(local[i]) · (parent<0 ? sk.initial_placement : world[parent])
|
// world[i] = Composite(local[i]) · (parent<0 ? root_offset : world[parent])
|
||||||
// skin[i] = sk.bones[i].inverse_world · world[i]
|
// skin[i] = sk.bones[i].inverse_world · world[i]
|
||||||
// tracks 按骨骼名匹配 sk;无匹配的骨骼用 sk 的 bind LocalTransform。
|
// tracks 按骨骼名匹配 sk;无匹配的骨骼用 sk 的 bind LocalTransform。
|
||||||
|
//
|
||||||
|
// root_offset = GrannyBuildWorldPose 的 Offset4x4。默认(nullptr)用
|
||||||
|
// sk.initial_placement —— 与 oracle/oracle.c 的 GrannyGetModelInitialPlacement4x4
|
||||||
|
// 一致,是 bind pose 与 inverse_world 自洽所要求的那一个(bind_pose 只用这一种)。
|
||||||
|
// 渲染动画时客户端另有取法:EterGrnLib 对 Offset4x4 传身位矩阵而不是导出期的
|
||||||
|
// InitialPlacement,而 Metin2 的 .gr2 动画 track 里根骨的位移已经含了骨盆高度,
|
||||||
|
// 所以再乘一次 InitialPlacement 会把整个人抬高一个骨盆高。要那条语义就显式传
|
||||||
|
// 单位阵(见 extension/src/metin2_anim.cpp 与 test/rendering/README.md 的实测)。
|
||||||
void sample_pose(const Skeleton& sk, const Animation& an, float t,
|
void sample_pose(const Skeleton& sk, const Animation& an, float t,
|
||||||
std::vector<Mat4>& world, std::vector<Mat4>& skin);
|
std::vector<Mat4>& world, std::vector<Mat4>& skin,
|
||||||
|
const Mat4* root_offset = nullptr);
|
||||||
void bind_pose(const Skeleton& sk, std::vector<Mat4>& world, std::vector<Mat4>& skin);
|
void bind_pose(const Skeleton& sk, std::vector<Mat4>& world, std::vector<Mat4>& skin);
|
||||||
|
|
||||||
// 打印辅助
|
// 打印辅助
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
|
|
||||||
namespace gr2 {
|
namespace gr2 {
|
||||||
|
|
||||||
using Mat4 = std::array<float, 16>; // 行主序,Granny 原生。坐标约定见 docs/steps/M0-gr2-reader.md「约定」
|
using Mat4 = std::array<float, 16>; // 行主序,Granny 原生。坐标约定见 docs/reference/steps/M0-gr2-reader.md「约定」
|
||||||
|
|
||||||
// ── 骨架 ──────────────────────────────────────────────────────────────
|
// ── 骨架 ──────────────────────────────────────────────────────────────
|
||||||
struct Bone {
|
struct Bone {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// M0 T7a —— 曲线子类型分类(gr2fuzz 直方图依赖)。T7b(解码)见文末。
|
// M0 T7a —— 曲线子类型分类(gr2fuzz 直方图依赖)。T7b(解码)见文末。
|
||||||
// 见 docs/steps/M0-gr2-reader.md
|
// 见 docs/reference/steps/M0-gr2-reader.md
|
||||||
// 规格:granny_track_group.cpp (TrackGroupType/TransformTrackType/Curve2Type) /
|
// 规格:granny_track_group.cpp (TrackGroupType/TransformTrackType/Curve2Type) /
|
||||||
// granny_curve.cpp (CurveData*Type,均以 CurveDataHeaderType 开头) /
|
// granny_curve.cpp (CurveData*Type,均以 CurveDataHeaderType 开头) /
|
||||||
// granny_curve.h (curve_data_header{ u8 Format; u8 Degree })
|
// granny_curve.h (curve_data_header{ u8 Format; u8 Degree })
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// M0 T2 —— section 解压分派。见 docs/steps/M0-gr2-reader.md
|
// M0 T2 —— section 解压分派。见 docs/reference/steps/M0-gr2-reader.md
|
||||||
#include "internal.h"
|
#include "internal.h"
|
||||||
#include "oodle1.h"
|
#include "oodle1.h"
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// M0 T1 —— header + section table + fixup。见 docs/steps/M0-gr2-reader.md
|
// M0 T1 —— header + section table + fixup。见 docs/reference/steps/M0-gr2-reader.md
|
||||||
// 规格:MobileSource/Cross Platform/Granny-3D-SDK-main/source/granny_file_format.h + .cpp
|
// 规格:MobileSource/Cross Platform/Granny-3D-SDK-main/source/granny_file_format.h + .cpp
|
||||||
//
|
//
|
||||||
// 本次实现范围:magic 识别 + grn_file_header + section 表解析(--sections 用)。
|
// 本次实现范围:magic 识别 + grn_file_header + section 表解析(--sections 用)。
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// M0 T4 —— FileInfo 汇总(驱动 T5 骨架 / T6 网格 / T7a 曲线分类)。
|
// M0 T4 —— FileInfo 汇总(驱动 T5 骨架 / T6 网格 / T7a 曲线分类)。
|
||||||
// 见 docs/steps/M0-gr2-reader.md
|
// 见 docs/reference/steps/M0-gr2-reader.md
|
||||||
// 规格:granny_file_info.cpp (FileInfoType) / granny_animation.cpp (AnimationType)
|
// 规格:granny_file_info.cpp (FileInfoType) / granny_animation.cpp (AnimationType)
|
||||||
#include "internal.h"
|
#include "internal.h"
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// M0 T6 —— 顶点 / 索引 / 三角组 / BoneBindings。见 docs/steps/M0-gr2-reader.md
|
// M0 T6 —— 顶点 / 索引 / 三角组 / BoneBindings。见 docs/reference/steps/M0-gr2-reader.md
|
||||||
// 规格:granny_mesh.cpp (MeshType/BoneBindingType) / granny_vertex_data.cpp (VertexDataType
|
// 规格:granny_mesh.cpp (MeshType/BoneBindingType) / granny_vertex_data.cpp (VertexDataType
|
||||||
// + 各顶点格式) / granny_tri_topology.cpp (TriTopologyType/TriMaterialGroupType)
|
// + 各顶点格式) / granny_tri_topology.cpp (TriTopologyType/TriMaterialGroupType)
|
||||||
// 顶点格式是自描述的(Vertices 是 ReferenceToVariantArray,带 data_type_definition*),
|
// 顶点格式是自描述的(Vertices 是 ReferenceToVariantArray,带 data_type_definition*),
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// M0 T5 —— 骨架提取 + bind pose 自洽检查。见 docs/steps/M0-gr2-reader.md
|
// M0 T5 —— 骨架提取 + bind pose 自洽检查。见 docs/reference/steps/M0-gr2-reader.md
|
||||||
// 规格:granny_transform.cpp BuildCompositeTransform4x4 / granny_math.cpp
|
// 规格:granny_transform.cpp BuildCompositeTransform4x4 / granny_math.cpp
|
||||||
// MatrixEqualsQuaternion3x3 + MatrixMultiply3x3 /
|
// MatrixEqualsQuaternion3x3 + MatrixMultiply3x3 /
|
||||||
// granny_matrix_operations.cpp ColumnMatrixMultiply4x3Impl /
|
// granny_matrix_operations.cpp ColumnMatrixMultiply4x3Impl /
|
||||||
@@ -131,14 +131,15 @@ bool File::Impl::extract_skeleton(Ref skel_obj, Ref skel_type, Skeleton& out) {
|
|||||||
// ── M2:姿势采样(自由函数 = 主 primitive;File::* 是便捷封装)──────────
|
// ── M2:姿势采样(自由函数 = 主 primitive;File::* 是便捷封装)──────────
|
||||||
namespace {
|
namespace {
|
||||||
void accumulate_world(const Skeleton& sk, const std::vector<Mat4>& local,
|
void accumulate_world(const Skeleton& sk, const std::vector<Mat4>& local,
|
||||||
std::vector<Mat4>& world, std::vector<Mat4>& skin) {
|
std::vector<Mat4>& world, std::vector<Mat4>& skin,
|
||||||
|
const Mat4& root_offset) {
|
||||||
const size_t n = sk.bones.size();
|
const size_t n = sk.bones.size();
|
||||||
const Mat4 I{1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1};
|
const Mat4 I{1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1};
|
||||||
world.assign(n, I);
|
world.assign(n, I);
|
||||||
skin.assign(n, I);
|
skin.assign(n, I);
|
||||||
for (size_t i = 0; i < n; ++i) {
|
for (size_t i = 0; i < n; ++i) {
|
||||||
int32_t par = sk.bones[i].parent;
|
int32_t par = sk.bones[i].parent;
|
||||||
const Mat4& parent_w = (par < 0 || (size_t)par >= i) ? sk.initial_placement : world[par];
|
const Mat4& parent_w = (par < 0 || (size_t)par >= i) ? root_offset : world[par];
|
||||||
world[i] = mul4x3((i < local.size()) ? local[i] : sk.bones[i].local_transform, parent_w);
|
world[i] = mul4x3((i < local.size()) ? local[i] : sk.bones[i].local_transform, parent_w);
|
||||||
skin[i] = mul4x3(sk.bones[i].inverse_world, world[i]);
|
skin[i] = mul4x3(sk.bones[i].inverse_world, world[i]);
|
||||||
}
|
}
|
||||||
@@ -148,11 +149,12 @@ void accumulate_world(const Skeleton& sk, const std::vector<Mat4>& local,
|
|||||||
void bind_pose(const Skeleton& sk, std::vector<Mat4>& world, std::vector<Mat4>& skin) {
|
void bind_pose(const Skeleton& sk, std::vector<Mat4>& world, std::vector<Mat4>& skin) {
|
||||||
std::vector<Mat4> local(sk.bones.size());
|
std::vector<Mat4> local(sk.bones.size());
|
||||||
for (size_t i = 0; i < sk.bones.size(); ++i) local[i] = sk.bones[i].local_transform;
|
for (size_t i = 0; i < sk.bones.size(); ++i) local[i] = sk.bones[i].local_transform;
|
||||||
accumulate_world(sk, local, world, skin);
|
accumulate_world(sk, local, world, skin, sk.initial_placement);
|
||||||
}
|
}
|
||||||
|
|
||||||
void sample_pose(const Skeleton& sk, const Animation& an, float t,
|
void sample_pose(const Skeleton& sk, const Animation& an, float t,
|
||||||
std::vector<Mat4>& world, std::vector<Mat4>& skin) {
|
std::vector<Mat4>& world, std::vector<Mat4>& skin,
|
||||||
|
const Mat4* root_offset) {
|
||||||
std::vector<Mat4> track_local;
|
std::vector<Mat4> track_local;
|
||||||
an.sample_local(t, track_local);
|
an.sample_local(t, track_local);
|
||||||
|
|
||||||
@@ -167,7 +169,7 @@ void sample_pose(const Skeleton& sk, const Animation& an, float t,
|
|||||||
if (sk.bones[k].name == tr.bone_name) { bi = int(k); break; }
|
if (sk.bones[k].name == tr.bone_name) { bi = int(k); break; }
|
||||||
if (bi >= 0) local[bi] = track_local[ti];
|
if (bi >= 0) local[bi] = track_local[ti];
|
||||||
}
|
}
|
||||||
accumulate_world(sk, local, world, skin);
|
accumulate_world(sk, local, world, skin, root_offset ? *root_offset : sk.initial_placement);
|
||||||
}
|
}
|
||||||
|
|
||||||
bool File::bind_pose(int skeleton, std::vector<Mat4>& world, std::vector<Mat4>& skin) const {
|
bool File::bind_pose(int skeleton, std::vector<Mat4>& world, std::vector<Mat4>& skin) const {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// M0 T1(fixup 索引)+ T3(自描述类型树遍历)。见 docs/steps/M0-gr2-reader.md
|
// M0 T1(fixup 索引)+ T3(自描述类型树遍历)。见 docs/reference/steps/M0-gr2-reader.md
|
||||||
// 规格:granny_file_format.h(fixup)+ granny_data_type_definition.h/.cpp(成员类型 + stride)
|
// 规格:granny_file_format.h(fixup)+ granny_data_type_definition.h/.cpp(成员类型 + stride)
|
||||||
#include "internal.h"
|
#include "internal.h"
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
* Ported (decode path only) from the leaked Granny 2.9.12 SDK:
|
* Ported (decode path only) from the leaked Granny 2.9.12 SDK:
|
||||||
* granny_oodle1_compression.cpp + radlz.c + radarith.c + arithbit.c
|
* granny_oodle1_compression.cpp + radlz.c + radarith.c + arithbit.c
|
||||||
* Used as algorithm spec; RAD's build machinery / compressor are NOT included.
|
* Used as algorithm spec; RAD's build machinery / compressor are NOT included.
|
||||||
* See docs/steps/M0-gr2-reader.md T2.
|
* See docs/reference/steps/M0-gr2-reader.md T2.
|
||||||
*/
|
*/
|
||||||
#include "oodle1.h"
|
#include "oodle1.h"
|
||||||
#include <stdlib.h>
|
#include <stdlib.h>
|
||||||
|
|||||||
@@ -0,0 +1,243 @@
|
|||||||
|
# R2-01 角色实验台 —— 无网络、无地图,只装配一个角色并从固定机位取近景。
|
||||||
|
#
|
||||||
|
# godot --path project --script char_bench_test.gd -- --race 0
|
||||||
|
# godot --path project --script char_bench_test.gd -- --race 0 --variant no_hair
|
||||||
|
# godot --path project --script char_bench_test.gd -- --body <gr2> --hair <gr2> --skin <dds>
|
||||||
|
#
|
||||||
|
# 目的是把「发型挡脸」拆成几何问题和材质/深度问题:同一机位分别渲染
|
||||||
|
# full 身体+发型(生产路径)
|
||||||
|
# no_hair 只有身体(脸是否本来就正确)
|
||||||
|
# hair_only 只有发型(发型网格实际落在哪、包围盒多大)
|
||||||
|
# unlit 身体+发型,禁用光照/阴影的诊断材质(排除光照与阴影影响)
|
||||||
|
# wire 身体+发型,线框(几何形状判定)
|
||||||
|
# 每个变体输出 front/side/back/face/feet 五个机位,附 report.json 记录
|
||||||
|
# 表面数、包围盒、材质模式和资源哈希。它不做像素阈值判定,只产证据。
|
||||||
|
extends SceneTree
|
||||||
|
|
||||||
|
const PlayerView = preload("res://ui/player_view.gd")
|
||||||
|
|
||||||
|
var _out := ""
|
||||||
|
var _report := {}
|
||||||
|
# 生产材质就挂在 surface override 上(cpu_skin 每帧重挂)。冻结动画后 cpu_skin
|
||||||
|
# 不再跑,所以变体切换必须把它们存下来再还原 —— 早先用 null "还原" 等于把贴图
|
||||||
|
# 材质丢掉,五个变体全渲成白模。
|
||||||
|
var _base_mats: Array = []
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
call_deferred("run")
|
||||||
|
|
||||||
|
func _arg(name: String, dflt := "") -> String:
|
||||||
|
var args := OS.get_cmdline_user_args()
|
||||||
|
var i := args.find("--" + name)
|
||||||
|
return args[i + 1] if i >= 0 and i + 1 < args.size() else dflt
|
||||||
|
|
||||||
|
func _flag(name: String) -> bool:
|
||||||
|
return ("--" + name) in OS.get_cmdline_user_args()
|
||||||
|
|
||||||
|
func run() -> void:
|
||||||
|
root.size = Vector2i(720, 720)
|
||||||
|
_out = OS.get_environment("MT_RENDER_OUTPUT")
|
||||||
|
if _out.is_empty():
|
||||||
|
_out = ProjectSettings.globalize_path("res://../build/rendering/charbench-%d"
|
||||||
|
% Time.get_unix_time_from_system())
|
||||||
|
DirAccess.make_dir_recursive_absolute(_out)
|
||||||
|
|
||||||
|
var race := int(_arg("race", "0"))
|
||||||
|
var assets := AssetRoot.path()
|
||||||
|
var pv := PlayerView.new()
|
||||||
|
root.add_child(pv)
|
||||||
|
if not pv.build(assets, race):
|
||||||
|
printerr("CHARBENCH FAIL: build(race=%d) returned false" % race)
|
||||||
|
quit(1)
|
||||||
|
return
|
||||||
|
|
||||||
|
# 允许覆盖具体身体/发型/贴图,以便复现用户出错的那一套。
|
||||||
|
var body := _arg("body")
|
||||||
|
if not body.is_empty(): pv.set("gr2_path", body)
|
||||||
|
var hair := _arg("hair")
|
||||||
|
if not hair.is_empty(): pv.set("hair_gr2", hair)
|
||||||
|
var skin := _arg("skin")
|
||||||
|
if not skin.is_empty(): pv.set("hair_skin", skin)
|
||||||
|
|
||||||
|
var anim := _arg("anim", "wait")
|
||||||
|
pv.set_anim_state(anim)
|
||||||
|
var sample := float(_arg("time", "0.5"))
|
||||||
|
await process_frame
|
||||||
|
await process_frame
|
||||||
|
if pv.anim:
|
||||||
|
pv.anim.call("set_time", sample)
|
||||||
|
pv.anim.set_process(false)
|
||||||
|
pv.rotation = Vector3.ZERO
|
||||||
|
|
||||||
|
_light()
|
||||||
|
var cam := Camera3D.new()
|
||||||
|
cam.fov = 40.0
|
||||||
|
cam.near = 0.02
|
||||||
|
root.add_child(cam)
|
||||||
|
cam.make_current()
|
||||||
|
|
||||||
|
_report = {
|
||||||
|
"race": race, "anim": anim, "sample_seconds": sample,
|
||||||
|
"body_gr2": String(pv.model.get("gr2_path")),
|
||||||
|
"hair_gr2": String(pv.model.get("hair_gr2")),
|
||||||
|
"hair_skin": String(pv.model.get("hair_skin")),
|
||||||
|
"ground_offset": pv.model.position.y,
|
||||||
|
"model_info": String(pv.model.call("get_info")),
|
||||||
|
"engine": Engine.get_version_info(),
|
||||||
|
"viewport": str(root.size),
|
||||||
|
"scope": "isolated bench: no map, no network, no 40250 pixel comparison",
|
||||||
|
"variants": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
var variants := PackedStringArray(["full", "no_hair", "hair_only", "unlit", "wire"])
|
||||||
|
var only := _arg("variant")
|
||||||
|
if not only.is_empty():
|
||||||
|
variants = PackedStringArray([only])
|
||||||
|
for v in variants:
|
||||||
|
await _shoot(pv, cam, v)
|
||||||
|
|
||||||
|
_report["sha256"] = _hashes(pv)
|
||||||
|
var f := FileAccess.open(_out.path_join("report.json"), FileAccess.WRITE)
|
||||||
|
f.store_string(JSON.stringify(_report, "\t"))
|
||||||
|
f.close()
|
||||||
|
print("CHARBENCH: ", _out)
|
||||||
|
print(JSON.stringify(_report))
|
||||||
|
quit(0)
|
||||||
|
|
||||||
|
func _light() -> void:
|
||||||
|
var sun := DirectionalLight3D.new()
|
||||||
|
sun.rotation_degrees = Vector3(-42, 155, 0)
|
||||||
|
sun.light_energy = 1.1
|
||||||
|
sun.set_layer_mask(0xFFFFF) # CharacterLight 层:模型 mi 用 layer 2
|
||||||
|
root.add_child(sun)
|
||||||
|
var env := WorldEnvironment.new()
|
||||||
|
var e := Environment.new()
|
||||||
|
e.background_mode = Environment.BG_COLOR
|
||||||
|
e.background_color = Color(0.15, 0.16, 0.2)
|
||||||
|
e.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
|
||||||
|
e.ambient_light_color = Color(0.6, 0.62, 0.68)
|
||||||
|
e.ambient_light_energy = 0.8
|
||||||
|
env.environment = e
|
||||||
|
root.add_child(env)
|
||||||
|
|
||||||
|
# 每个变体:改可见性/材质,然后跑五个机位。
|
||||||
|
func _shoot(pv: Node3D, cam: Camera3D, variant: String) -> void:
|
||||||
|
var meshes := _meshes(pv)
|
||||||
|
var body_mi: MeshInstance3D = null
|
||||||
|
for mi in meshes:
|
||||||
|
if mi.name == "MeshInstance3D": body_mi = mi
|
||||||
|
if body_mi == null:
|
||||||
|
printerr("CHARBENCH: no body MeshInstance3D for variant ", variant)
|
||||||
|
return
|
||||||
|
# 身体 surface 数(发型折进同一个 mesh 的尾部 surface)。
|
||||||
|
var total := body_mi.mesh.get_surface_count()
|
||||||
|
if _base_mats.size() != total:
|
||||||
|
_base_mats.clear()
|
||||||
|
for s in total:
|
||||||
|
_base_mats.append(body_mi.get_surface_override_material(s))
|
||||||
|
var hair_first := int(_report.get("hair_surface_first", -1))
|
||||||
|
if hair_first < 0:
|
||||||
|
hair_first = _hair_surface_first(pv, body_mi)
|
||||||
|
_report["hair_surface_first"] = hair_first
|
||||||
|
|
||||||
|
_restore(body_mi, total)
|
||||||
|
var is_hair := func(s: int) -> bool: return hair_first >= 0 and s >= hair_first
|
||||||
|
match variant:
|
||||||
|
"no_hair":
|
||||||
|
for s in total:
|
||||||
|
if is_hair.call(s): body_mi.set_surface_override_material(s, _hidden())
|
||||||
|
"hair_only":
|
||||||
|
for s in total:
|
||||||
|
if not is_hair.call(s): body_mi.set_surface_override_material(s, _hidden())
|
||||||
|
"unlit":
|
||||||
|
for s in total:
|
||||||
|
body_mi.set_surface_override_material(s,
|
||||||
|
_diag(Color(1, 0.35, 0.2) if is_hair.call(s) else Color(0.35, 0.6, 1.0), false))
|
||||||
|
"wire":
|
||||||
|
for s in total:
|
||||||
|
body_mi.set_surface_override_material(s,
|
||||||
|
_diag(Color(1, 0.35, 0.2) if is_hair.call(s) else Color(0.35, 0.6, 1.0), true))
|
||||||
|
|
||||||
|
var aabb: AABB = body_mi.get_aabb()
|
||||||
|
var vr := {"surfaces": total, "hair_surface_first": hair_first,
|
||||||
|
"local_aabb": str(aabb), "shots": {}}
|
||||||
|
for shot in _shots():
|
||||||
|
cam.global_transform = Transform3D(Basis(), shot["eye"] as Vector3) \
|
||||||
|
.looking_at(shot["at"] as Vector3, Vector3.UP)
|
||||||
|
cam.fov = float(shot["fov"])
|
||||||
|
await RenderingServer.frame_post_draw
|
||||||
|
await RenderingServer.frame_post_draw
|
||||||
|
var name := "%s-%s.png" % [variant, shot["name"]]
|
||||||
|
var err := root.get_texture().get_image().save_png(_out.path_join(name))
|
||||||
|
vr["shots"][String(shot["name"])] = name if err == OK else "SAVE_FAILED"
|
||||||
|
_report["variants"][variant] = vr
|
||||||
|
_restore(body_mi, total)
|
||||||
|
|
||||||
|
func _restore(body_mi: MeshInstance3D, total: int) -> void:
|
||||||
|
for s in total:
|
||||||
|
body_mi.set_surface_override_material(s, _base_mats[s] if s < _base_mats.size() else null)
|
||||||
|
|
||||||
|
# 头顶约 1.74 m;机位单位是米,正面在 +Z。
|
||||||
|
func _shots() -> Array:
|
||||||
|
return [
|
||||||
|
{"name": "front", "eye": Vector3(0, 1.0, 3.1), "at": Vector3(0, 0.9, 0), "fov": 40},
|
||||||
|
{"name": "side", "eye": Vector3(3.1, 1.0, 0), "at": Vector3(0, 0.9, 0), "fov": 40},
|
||||||
|
{"name": "back", "eye": Vector3(0, 1.0, -3.1), "at": Vector3(0, 0.9, 0), "fov": 40},
|
||||||
|
{"name": "face", "eye": Vector3(0, 1.68, 0.75), "at": Vector3(0, 1.62, 0), "fov": 32},
|
||||||
|
{"name": "face_side", "eye": Vector3(0.75, 1.68, 0.05), "at": Vector3(0, 1.62, 0), "fov": 32},
|
||||||
|
{"name": "feet", "eye": Vector3(0.35, 0.30, 0.75), "at": Vector3(0, 0.06, 0), "fov": 32},
|
||||||
|
]
|
||||||
|
|
||||||
|
func _meshes(pv: Node3D) -> Array:
|
||||||
|
return pv.find_children("*", "MeshInstance3D", true, false)
|
||||||
|
|
||||||
|
# 发型被折进 body mesh 的末尾若干 surface。清 hair_gr2 再数一遍是不行的:
|
||||||
|
# surface 数要等下一次 cpu_skin 才变,读回来还是折叠后的值(旧实现因此恒返回
|
||||||
|
# -1,所有变体渲染成同一张图)。get_info() 报的 surfaces= 是身体 gr2 自己的
|
||||||
|
# surface 数,与折叠无关,直接拿它当分界。
|
||||||
|
func _hair_surface_first(pv: Node3D, body_mi: MeshInstance3D) -> int:
|
||||||
|
if String(pv.model.get("hair_gr2")).is_empty():
|
||||||
|
return -1
|
||||||
|
var total := body_mi.mesh.get_surface_count()
|
||||||
|
var info := String(pv.model.call("get_info"))
|
||||||
|
var body_surfaces := -1
|
||||||
|
for token in info.split(" ", false):
|
||||||
|
if token.begins_with("surfaces="):
|
||||||
|
body_surfaces = int(token.substr(9))
|
||||||
|
if body_surfaces <= 0 or body_surfaces >= total:
|
||||||
|
printerr("CHARBENCH: cannot split hair surfaces (info=%s, mesh surfaces=%d)" % [info, total])
|
||||||
|
return -1
|
||||||
|
return body_surfaces
|
||||||
|
|
||||||
|
func _hidden() -> StandardMaterial3D:
|
||||||
|
var m := StandardMaterial3D.new()
|
||||||
|
m.transparency = BaseMaterial3D.TRANSPARENCY_DISABLED
|
||||||
|
m.no_depth_test = false
|
||||||
|
m.render_priority = 0
|
||||||
|
m.albedo_color = Color(0, 0, 0, 0)
|
||||||
|
m.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
||||||
|
m.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||||
|
m.depth_draw_mode = BaseMaterial3D.DEPTH_DRAW_DISABLED
|
||||||
|
return m
|
||||||
|
|
||||||
|
func _diag(c: Color, wire: bool) -> StandardMaterial3D:
|
||||||
|
var m := StandardMaterial3D.new()
|
||||||
|
m.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||||
|
m.albedo_color = c
|
||||||
|
m.cull_mode = BaseMaterial3D.CULL_DISABLED
|
||||||
|
if wire:
|
||||||
|
m.albedo_color = Color(c.r, c.g, c.b, 0.35)
|
||||||
|
m.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
||||||
|
m.depth_draw_mode = BaseMaterial3D.DEPTH_DRAW_DISABLED
|
||||||
|
return m
|
||||||
|
|
||||||
|
func _hashes(pv: Node3D) -> Dictionary:
|
||||||
|
var h := {}
|
||||||
|
for p in ["res://ui/player_view.gd", "res://char_bench_test.gd",
|
||||||
|
"res://bin/libmtgodot.macos.template_debug.dylib"]:
|
||||||
|
h[p] = FileAccess.get_sha256(p)
|
||||||
|
for prop in ["gr2_path", "hair_gr2", "hair_skin"]:
|
||||||
|
var path := String(pv.model.get(prop))
|
||||||
|
if not path.is_empty() and FileAccess.file_exists(path):
|
||||||
|
h[path] = FileAccess.get_sha256(path)
|
||||||
|
return h
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://yu8c7genhjae
|
||||||
@@ -7,8 +7,18 @@ extends Node
|
|||||||
|
|
||||||
const AppFlow = preload("res://app_flow.gd")
|
const AppFlow = preload("res://app_flow.gd")
|
||||||
const LiveSmokeTest = preload("res://live_smoke_test.gd")
|
const LiveSmokeTest = preload("res://live_smoke_test.gd")
|
||||||
|
const PackageRenderTest = preload("res://package_render_test.gd")
|
||||||
|
const NetTrace = preload("res://net_trace.gd")
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
|
# MT_TEST_MODE=render:包内离线渲染自检,不起 AppFlow、不连服务器。
|
||||||
|
# 导出模板不接受 --script,这是导出包里唯一能跑离线渲染回归的入口。
|
||||||
|
if OS.get_environment("MT_TEST_MODE") == "render":
|
||||||
|
AssetPack.ensure()
|
||||||
|
var t := PackageRenderTest.new()
|
||||||
|
t.name = "PackageRenderTest"
|
||||||
|
add_child(t)
|
||||||
|
return
|
||||||
# 打包 app 从 Finder/open 启动时没有 shell 环境;40250 服务器使用 classic
|
# 打包 app 从 Finder/open 启动时没有 shell 环境;40250 服务器使用 classic
|
||||||
# DH2/CTR 协议,因此默认固定到 classic,同时保留 MT_PROTOCOL 覆盖能力。
|
# DH2/CTR 协议,因此默认固定到 classic,同时保留 MT_PROTOCOL 覆盖能力。
|
||||||
if OS.get_environment("MT_PROTOCOL").is_empty():
|
if OS.get_environment("MT_PROTOCOL").is_empty():
|
||||||
@@ -22,6 +32,13 @@ func _ready() -> void:
|
|||||||
af.name = "AppFlow"
|
af.name = "AppFlow"
|
||||||
add_child(af)
|
add_child(af)
|
||||||
af.start(root)
|
af.start(root)
|
||||||
|
# 主角 vid 生命周期取证(见 net_trace.gd):排在 AppFlow 之后,保证每帧
|
||||||
|
# 在 client 派发完信号之后再读 main_vid。
|
||||||
|
if af.client != null:
|
||||||
|
var trace := NetTrace.new()
|
||||||
|
trace.name = "NetTrace"
|
||||||
|
add_child(trace)
|
||||||
|
trace.setup(af.client)
|
||||||
if OS.get_environment("MT_TEST_MODE") == "smoke":
|
if OS.get_environment("MT_TEST_MODE") == "smoke":
|
||||||
var smoke := LiveSmokeTest.new()
|
var smoke := LiveSmokeTest.new()
|
||||||
smoke.name = "LiveSmokeTest"
|
smoke.name = "LiveSmokeTest"
|
||||||
|
|||||||
@@ -130,6 +130,19 @@ var _main_retry_at := 0
|
|||||||
var _main_placed_vid := 0
|
var _main_placed_vid := 0
|
||||||
var _main_map_vid := 0
|
var _main_map_vid := 0
|
||||||
const MAIN_MODEL_MAX_ATTEMPTS := 3
|
const MAIN_MODEL_MAX_ATTEMPTS := 3
|
||||||
|
|
||||||
|
# 实机「灰紫虚空 + 蓝胶囊」的取证钩子。离线自检(package_render_test.gd)在导出包里
|
||||||
|
# 是全绿的,所以这个故障只出现在服务器驱动的进场路径上,而实机唯一的产物是一张截图。
|
||||||
|
# 进游戏 DIAG_AFTER_MS 后如果地图或主角模型任一没落地,就把决定性的几个值打一次到
|
||||||
|
# godot.log —— 让下一次实机复现直接给出原因,而不是再来一张截图。
|
||||||
|
# MT_DIAG=1 时无论成败都打。
|
||||||
|
const DIAG_AFTER_MS := 8000
|
||||||
|
const DIAG_REPEAT_MS := 2000 # MT_DIAG=1 时连打几次,看主角装配是"没跑"还是"没跑完"
|
||||||
|
const DIAG_REPEATS := 6
|
||||||
|
var _diag_at := 0
|
||||||
|
var _diag_done := false
|
||||||
|
var _diag_left := 1
|
||||||
|
var _diag_started := false
|
||||||
const MAIN_MODEL_RETRY_MS := 1000
|
const MAIN_MODEL_RETRY_MS := 1000
|
||||||
var _audio: Node
|
var _audio: Node
|
||||||
var _bgm_director: Node
|
var _bgm_director: Node
|
||||||
@@ -153,6 +166,8 @@ func setup(m2client: Node, assets_root: String,
|
|||||||
initial_map_path: String = "OutdoorA1/metin2_map_a1") -> void:
|
initial_map_path: String = "OutdoorA1/metin2_map_a1") -> void:
|
||||||
client = m2client
|
client = m2client
|
||||||
_assets = assets_root
|
_assets = assets_root
|
||||||
|
var diag_after := int(OS.get_environment("MT_DIAG_AFTER_MS")) if OS.get_environment("MT_DIAG_AFTER_MS") != "" else DIAG_AFTER_MS
|
||||||
|
_diag_at = Time.get_ticks_msec() + diag_after
|
||||||
_ui_mobile = UiProfile.resolve() == UiProfile.Mode.MOBILE
|
_ui_mobile = UiProfile.resolve() == UiProfile.Mode.MOBILE
|
||||||
# GC_MAIN_CHARACTER only carries the server-space coordinates, not the map
|
# GC_MAIN_CHARACTER only carries the server-space coordinates, not the map
|
||||||
# folder. The old fixed A1 fallback puts characters from A2/A3 outside the
|
# folder. The old fixed A1 fallback puts characters from A2/A3 outside the
|
||||||
@@ -658,6 +673,7 @@ func _process(_dt: float) -> void:
|
|||||||
# Without it the local player stays as the blue CapsuleMesh forever and the
|
# Without it the local player stays as the blue CapsuleMesh forever and the
|
||||||
# camera follows an ungrounded placeholder, which makes the map look empty.
|
# camera follows an ungrounded placeholder, which makes the map look empty.
|
||||||
_sync_main_character()
|
_sync_main_character()
|
||||||
|
_diag_tick()
|
||||||
# MilesLib::CSoundManager::SetPosition/SetDirection + Update. The audio
|
# MilesLib::CSoundManager::SetPosition/SetDirection + Update. The audio
|
||||||
# node keeps its listener at the origin and rebases active 3D instances.
|
# node keeps its listener at the origin and rebases active 3D instances.
|
||||||
if _audio == null or player == null or not is_instance_valid(player):
|
if _audio == null or player == null or not is_instance_valid(player):
|
||||||
@@ -667,6 +683,54 @@ func _process(_dt: float) -> void:
|
|||||||
if world and world.has_method("get_ambience_sources") and _audio.has_method("update_ambience_sources"):
|
if world and world.has_method("get_ambience_sources") and _audio.has_method("update_ambience_sources"):
|
||||||
_audio.update_ambience_sources(world.call("get_ambience_sources"))
|
_audio.update_ambience_sources(world.call("get_ambience_sources"))
|
||||||
|
|
||||||
|
# 进游戏若干秒后的一次性取证。只在「地图或主角没落地」时输出,正常局内不产生噪音。
|
||||||
|
# 打印的每个值都对应 _build_world / _sync_main_character 里的一个具体分支,读日志的人
|
||||||
|
# 不用再猜是资源没找到、扩展没注册、race 越界还是坐标落在了别的地图上。
|
||||||
|
func _diag_tick() -> void:
|
||||||
|
if _diag_done or _diag_at == 0 or Time.get_ticks_msec() < _diag_at:
|
||||||
|
return
|
||||||
|
var forced := OS.get_environment("MT_DIAG") == "1"
|
||||||
|
if forced and not _diag_started:
|
||||||
|
_diag_started = true
|
||||||
|
_diag_left = DIAG_REPEATS
|
||||||
|
_diag_left -= 1
|
||||||
|
if _diag_left <= 0:
|
||||||
|
_diag_done = true
|
||||||
|
else:
|
||||||
|
_diag_at = Time.get_ticks_msec() + DIAG_REPEAT_MS
|
||||||
|
var in_game: bool = client != null and client.has_method("is_in_game") and client.is_in_game()
|
||||||
|
if not forced and (not in_game or (_model_built and _map_loaded())):
|
||||||
|
_diag_done = true
|
||||||
|
return
|
||||||
|
var vid := int(client.get_main_vid()) if client != null and client.has_method("get_main_vid") else 0
|
||||||
|
var e: Dictionary = client.get_entity(vid) if vid != 0 and client.has_method("get_entity") else {}
|
||||||
|
var d := {
|
||||||
|
"in_game": in_game, "model_built": _model_built, "map_loaded": _map_loaded(),
|
||||||
|
"map_path": map_path, "assets": _assets, "assets_exists": DirAccess.dir_exists_absolute(_assets),
|
||||||
|
"main_vid": vid, "race": e.get("race", null), "pos": str(e.get("pos", "")),
|
||||||
|
"pos_cm": str(e.get("pos_cm", "")), "entity_empty": e.is_empty(),
|
||||||
|
"main_attempts": _main_attempts, "main_model_attempted": _main_model_attempted,
|
||||||
|
"main_sync_ready": _main_sync_ready, "main_loading": _main_loading,
|
||||||
|
"main_view_key": _main_view_key, "main_map_vid": _main_map_vid,
|
||||||
|
"main_retry_in_ms": _main_retry_at - Time.get_ticks_msec(),
|
||||||
|
"t_ms": Time.get_ticks_msec(),
|
||||||
|
"classes": {"Metin2Model": ClassDB.class_exists("Metin2Model"),
|
||||||
|
"Metin2World": ClassDB.class_exists("Metin2World")},
|
||||||
|
"world_null": world == null,
|
||||||
|
"map_report": world.call("get_load_report") if world != null else {},
|
||||||
|
"player_pos": str(player.position) if player != null else "",
|
||||||
|
"map_base_set": MapCoord.has_base(),
|
||||||
|
"map_base_cm": str(MapCoord.to_server_cm(Vector3.ZERO)),
|
||||||
|
}
|
||||||
|
printerr("GAMESCENE_DIAG: ", JSON.stringify(d))
|
||||||
|
# MT_DIAG_SHOT=<png>:最后一次 tick 存一张实机帧,用于人工核对渲染结果。
|
||||||
|
var shot := OS.get_environment("MT_DIAG_SHOT")
|
||||||
|
if not shot.is_empty() and _diag_done:
|
||||||
|
await RenderingServer.frame_post_draw
|
||||||
|
var img := get_viewport().get_texture().get_image()
|
||||||
|
if img != null:
|
||||||
|
img.save_png(shot)
|
||||||
|
|
||||||
# 会徽上传图源:优先 res://ui/default_guild_mark.png,没有就现造一张 16×12 占位。
|
# 会徽上传图源:优先 res://ui/default_guild_mark.png,没有就现造一张 16×12 占位。
|
||||||
func _guild_mark_upload_image() -> Image:
|
func _guild_mark_upload_image() -> Image:
|
||||||
var p := "res://ui/default_guild_mark.png"
|
var p := "res://ui/default_guild_mark.png"
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ const GameScene = preload("res://game_scene.gd")
|
|||||||
const Fixtures = preload("res://gamescene_test.gd")
|
const Fixtures = preload("res://gamescene_test.gd")
|
||||||
var failed := false
|
var failed := false
|
||||||
|
|
||||||
|
# wait.gr2 @0.5s keeps the warrior planted; measured lowest vertex is +1.5 mm.
|
||||||
|
# 3 cm leaves room for terrain interpolation without hiding a real float.
|
||||||
|
const FEET_GAP_MAX := 0.03
|
||||||
|
|
||||||
func _initialize() -> void:
|
func _initialize() -> void:
|
||||||
call_deferred("run")
|
call_deferred("run")
|
||||||
|
|
||||||
@@ -39,13 +43,24 @@ func run() -> void:
|
|||||||
if scene._model_built and scene.player.anim:
|
if scene._model_built and scene.player.anim:
|
||||||
scene.player.anim.set_time(0.5)
|
scene.player.anim.set_time(0.5)
|
||||||
scene.player.anim.set_process(false)
|
scene.player.anim.set_process(false)
|
||||||
|
await process_frame # let the frozen pose reach cpu_skin before measuring
|
||||||
|
# R2-01 grounding: the lowest skinned vertex must sit on the terrain, not
|
||||||
|
# above it. Before the Offset4x4 fix the whole actor hovered one pelvis
|
||||||
|
# height (warrior 0.994 m) up; this is the numeric guard for that.
|
||||||
|
var feet_y := INF
|
||||||
|
for mi in scene.player.find_children("*", "MeshInstance3D", true, false):
|
||||||
|
if mi.mesh == null: continue
|
||||||
|
feet_y = minf(feet_y, (mi.global_transform * mi.get_aabb()).position.y)
|
||||||
|
var feet_gap := feet_y - expected.y
|
||||||
|
failed = failed or not is_finite(feet_gap) or absf(feet_gap) > FEET_GAP_MAX
|
||||||
scene.cam.snap_to_target()
|
scene.cam.snap_to_target()
|
||||||
if DisplayServer.get_name() != "headless":
|
if DisplayServer.get_name() != "headless":
|
||||||
await RenderingServer.frame_post_draw
|
await RenderingServer.frame_post_draw
|
||||||
failed = failed or root.get_texture().get_image().save_png(output.path_join("game.png")) != OK
|
failed = failed or root.get_texture().get_image().save_png(output.path_join("game.png")) != OK
|
||||||
var report := {"passed": not failed, "map": scene.map_path, "server_cm": str(server),
|
var report := {"passed": not failed, "map": scene.map_path, "server_cm": str(server),
|
||||||
"world_position": str(scene.player.position), "expected": str(expected),
|
"world_position": str(scene.player.position), "expected": str(expected),
|
||||||
"surfaces": surfaces, "map_report": scene.world.get_load_report(),
|
"surfaces": surfaces, "feet_y": feet_y, "feet_gap": feet_gap,
|
||||||
|
"feet_gap_max": FEET_GAP_MAX, "map_report": scene.world.get_load_report(),
|
||||||
"engine": Engine.get_version_info(), "viewport": str(root.size),
|
"engine": Engine.get_version_info(), "viewport": str(root.size),
|
||||||
"camera_transform": str(scene.cam.global_transform), "fov": scene.cam.fov,
|
"camera_transform": str(scene.cam.global_transform), "fov": scene.cam.fov,
|
||||||
"assets": AssetRoot.path(), "headless": DisplayServer.get_name() == "headless",
|
"assets": AssetRoot.path(), "headless": DisplayServer.get_name() == "headless",
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://bii82ax05bwuj
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dp333tqyl0dcg
|
||||||
@@ -0,0 +1,423 @@
|
|||||||
|
# model_render_test —— 不启动游戏、不连服务器的模型渲染自动回归。
|
||||||
|
#
|
||||||
|
# godot --path project --script model_render_test.gd
|
||||||
|
# godot --path project --script model_render_test.gd -- --race 0
|
||||||
|
# godot --path project --script model_render_test.gd -- --bless # 重建基线
|
||||||
|
#
|
||||||
|
# 与 char_bench_test.gd 的区别:bench 只产图、判定靠人眼;这里对渲染结果算数值
|
||||||
|
# 指标并断言,退出码即结论。覆盖范围仅限「离线装配的单角色」——没有地图、没有
|
||||||
|
# 光照环境的真实动态、没有 40250 像素比对,因此它不能替代 gamescene_visual_test
|
||||||
|
# 和导出包验收,只用来挡住模型/发型/材质改动引入的回归。
|
||||||
|
#
|
||||||
|
# 指标(每个 race):
|
||||||
|
# feet_gap_m 最低蒙皮顶点到 y=0 的距离(悬空/陷地)
|
||||||
|
# silhouette_px 正面剪影像素数(模型是否真的画出来了)
|
||||||
|
# hair_mean_lum 只画发型时,发型像素的平均亮度(多余高光会把它抬上去)
|
||||||
|
# hair_mean_lum_ctrl 同上,但换回旧的 StandardMaterial3D(roughness=1) —— 负对照,
|
||||||
|
# 用来证明这个指标现在仍然分得开好坏,而不是恒过
|
||||||
|
# head_cover full 与 no_hair 在近景机位的差异像素占比(发型遮挡量)
|
||||||
|
#
|
||||||
|
# 绝对断言不依赖基线;head_cover / silhouette_px 这类因模型而异的量对比
|
||||||
|
# test/rendering/model_baseline.json。改模型或改光照后用 --bless 重建基线,
|
||||||
|
# 并在计划书执行记录里写清为什么重建。
|
||||||
|
extends SceneTree
|
||||||
|
|
||||||
|
const PlayerView = preload("res://ui/player_view.gd")
|
||||||
|
const BASELINE := "res://../test/rendering/model_baseline.json"
|
||||||
|
|
||||||
|
# 渲染尺寸:指标在 GDScript 里逐像素统计,512 是精度和耗时的折中。
|
||||||
|
const SIZE := 512
|
||||||
|
const BG := Color(1, 0, 1) # 洋红背景,模型不会出现这个颜色
|
||||||
|
|
||||||
|
# --- 绝对阈值(不依赖基线)------------------------------------------------
|
||||||
|
# 脚底:bench 无地形,模型自带 ground_offset,实测四职业 |gap| <= 0.003 m。
|
||||||
|
const FEET_GAP_MAX := 0.03
|
||||||
|
# 剪影:正面 512x512 里角色至少占这么多像素,防止「什么都没画」也算过。
|
||||||
|
const SILHOUETTE_MIN := 3000
|
||||||
|
# 发型高光用「发型像素的平均亮度」度量。发色本身明暗差别很大(武士近黑、
|
||||||
|
# 修罗银白),所以没有跨职业通用的绝对上限:绝对判定只用下面的负对照差,
|
||||||
|
# 具体数值按职业记进基线。旧材质相对新材质的实测抬升是 +0.011 ~ +0.037
|
||||||
|
# (八个职业,见 test/rendering/README.md)。
|
||||||
|
const LUM_BRIGHT := 0.20 # hair_lum_p99 只作参考量,不参与判定
|
||||||
|
# 负对照必须比生产材质亮这么多,否则说明这条指标已经不分好坏了(例如有人把
|
||||||
|
# 灯光调暗,两边都测不出高光)。八职业最小实测差 0.0109。
|
||||||
|
const HAIR_CTRL_MARGIN := 0.008
|
||||||
|
|
||||||
|
# --- 基线容差 -------------------------------------------------------------
|
||||||
|
const TOL_HEAD_COVER := 0.03 # 绝对
|
||||||
|
const TOL_SILHOUETTE := 0.03 # 相对
|
||||||
|
const TOL_HAIR_LUM := 0.006 # 绝对;最小的「旧材质」信号是 0.0109
|
||||||
|
|
||||||
|
var _out := ""
|
||||||
|
var _bless := false
|
||||||
|
var _fail: Array[String] = []
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
call_deferred("run")
|
||||||
|
|
||||||
|
func _arg(name: String, dflt := "") -> String:
|
||||||
|
var args := OS.get_cmdline_user_args()
|
||||||
|
var i := args.find("--" + name)
|
||||||
|
return args[i + 1] if i >= 0 and i + 1 < args.size() else dflt
|
||||||
|
|
||||||
|
func run() -> void:
|
||||||
|
root.size = Vector2i(SIZE, SIZE)
|
||||||
|
_bless = "--bless" in OS.get_cmdline_user_args()
|
||||||
|
_out = OS.get_environment("MT_RENDER_OUTPUT")
|
||||||
|
if _out.is_empty():
|
||||||
|
_out = ProjectSettings.globalize_path("res://../build/rendering/modeltest-%d"
|
||||||
|
% Time.get_unix_time_from_system())
|
||||||
|
DirAccess.make_dir_recursive_absolute(_out)
|
||||||
|
|
||||||
|
var races: Array = []
|
||||||
|
var only := _arg("race")
|
||||||
|
if only.is_empty():
|
||||||
|
races = [0, 1, 2, 3, 4, 5, 6, 7]
|
||||||
|
else:
|
||||||
|
races = [int(only)]
|
||||||
|
|
||||||
|
var baseline := _load_baseline()
|
||||||
|
var results := {}
|
||||||
|
_light()
|
||||||
|
var cam := Camera3D.new()
|
||||||
|
cam.near = 0.02
|
||||||
|
root.add_child(cam)
|
||||||
|
cam.make_current()
|
||||||
|
|
||||||
|
for race in races:
|
||||||
|
var m := await _measure(int(race), cam)
|
||||||
|
if m.is_empty():
|
||||||
|
_fail.append("race %d: build failed" % race)
|
||||||
|
continue
|
||||||
|
results[str(race)] = m
|
||||||
|
_check_absolute(int(race), m)
|
||||||
|
if not _bless:
|
||||||
|
_check_baseline(int(race), m, baseline)
|
||||||
|
|
||||||
|
var report := {
|
||||||
|
"passed": _fail.is_empty(),
|
||||||
|
"failures": _fail,
|
||||||
|
"blessed": _bless,
|
||||||
|
"races": results,
|
||||||
|
"thresholds": {
|
||||||
|
"feet_gap_max": FEET_GAP_MAX, "silhouette_min": SILHOUETTE_MIN,
|
||||||
|
"hair_ctrl_margin": HAIR_CTRL_MARGIN, "tol_hair_lum": TOL_HAIR_LUM,
|
||||||
|
"tol_head_cover": TOL_HEAD_COVER, "tol_silhouette": TOL_SILHOUETTE,
|
||||||
|
},
|
||||||
|
"engine": Engine.get_version_info(),
|
||||||
|
"viewport": str(root.size),
|
||||||
|
"assets": AssetRoot.path(),
|
||||||
|
"headless": DisplayServer.get_name() == "headless",
|
||||||
|
"scope": "offline single-actor assembly; no map, no network, no 40250 pixel parity",
|
||||||
|
"sha256": {"res://bin/libmtgodot.macos.template_debug.dylib":
|
||||||
|
FileAccess.get_sha256("res://bin/libmtgodot.macos.template_debug.dylib"),
|
||||||
|
"res://model_render_test.gd": FileAccess.get_sha256("res://model_render_test.gd")},
|
||||||
|
}
|
||||||
|
var f := FileAccess.open(_out.path_join("report.json"), FileAccess.WRITE)
|
||||||
|
f.store_string(JSON.stringify(report, "\t"))
|
||||||
|
f.close()
|
||||||
|
if _bless:
|
||||||
|
_write_baseline(results)
|
||||||
|
print("MODELRENDER: baseline written -> ", ProjectSettings.globalize_path(BASELINE))
|
||||||
|
print("MODELRENDER: ", _out)
|
||||||
|
print(JSON.stringify(report))
|
||||||
|
for msg in _fail:
|
||||||
|
printerr("MODELRENDER FAIL: ", msg)
|
||||||
|
print("MODELRENDER: ", "PASS" if _fail.is_empty() else "FAIL")
|
||||||
|
quit(0 if _fail.is_empty() else 1)
|
||||||
|
|
||||||
|
# --- 单个 race 的测量 -----------------------------------------------------
|
||||||
|
|
||||||
|
func _measure(race: int, cam: Camera3D) -> Dictionary:
|
||||||
|
var pv := PlayerView.new()
|
||||||
|
root.add_child(pv)
|
||||||
|
if not pv.build(AssetRoot.path(), race):
|
||||||
|
pv.queue_free()
|
||||||
|
return {}
|
||||||
|
pv.set_anim_state("wait")
|
||||||
|
await process_frame
|
||||||
|
await process_frame
|
||||||
|
if pv.anim:
|
||||||
|
pv.anim.call("set_time", 0.5)
|
||||||
|
pv.anim.set_process(false)
|
||||||
|
pv.rotation = Vector3.ZERO
|
||||||
|
await process_frame
|
||||||
|
|
||||||
|
var body: MeshInstance3D = null
|
||||||
|
for mi in pv.find_children("*", "MeshInstance3D", true, false):
|
||||||
|
if mi.name == "MeshInstance3D":
|
||||||
|
body = mi
|
||||||
|
if body == null or body.mesh == null:
|
||||||
|
pv.queue_free()
|
||||||
|
return {}
|
||||||
|
var total := body.mesh.get_surface_count()
|
||||||
|
var base_mats: Array = []
|
||||||
|
for s in total:
|
||||||
|
base_mats.append(body.get_surface_override_material(s))
|
||||||
|
var hair_first := _hair_surface_first(pv, body, total)
|
||||||
|
|
||||||
|
# 脚底:最低蒙皮顶点(bench 里地面就是 y=0)
|
||||||
|
var feet_y := INF
|
||||||
|
for mi in pv.find_children("*", "MeshInstance3D", true, false):
|
||||||
|
if mi.mesh:
|
||||||
|
feet_y = minf(feet_y, (mi.global_transform * mi.get_aabb()).position.y)
|
||||||
|
|
||||||
|
var m := {
|
||||||
|
"body_gr2": String(pv.model.get("gr2_path")),
|
||||||
|
"hair_gr2": String(pv.model.get("hair_gr2")),
|
||||||
|
"surfaces": total, "hair_surface_first": hair_first,
|
||||||
|
"feet_gap_m": feet_y if is_finite(feet_y) else NAN,
|
||||||
|
}
|
||||||
|
|
||||||
|
# 1) 正面剪影
|
||||||
|
_aim(cam, Vector3(0, 1.0, 3.1), Vector3(0, 0.9, 0), 40.0)
|
||||||
|
var front := await _grab("race%d-front" % race)
|
||||||
|
m["silhouette_px"] = _count_fg(front)
|
||||||
|
|
||||||
|
# 2) 近景:full / no_hair 的差异 = 发型遮挡量
|
||||||
|
_aim(cam, Vector3(0.75, 1.68, 0.05), Vector3(0, 1.62, 0), 32.0)
|
||||||
|
var full := await _grab("race%d-face_side" % race)
|
||||||
|
if hair_first >= 0:
|
||||||
|
for s in range(hair_first, total):
|
||||||
|
body.set_surface_override_material(s, _hidden())
|
||||||
|
var nohair := await _grab("race%d-face_side-no_hair" % race)
|
||||||
|
_restore(body, base_mats)
|
||||||
|
var fg := _count_fg(nohair)
|
||||||
|
m["head_cover"] = (float(_count_diff(full, nohair)) / float(maxi(fg, 1))) if hair_first >= 0 else 0.0
|
||||||
|
m["head_px"] = fg
|
||||||
|
|
||||||
|
# 3) 发型亮度:只画发型,量 99 分位亮度;再用旧材质做负对照
|
||||||
|
if hair_first >= 0:
|
||||||
|
for s in range(0, hair_first):
|
||||||
|
body.set_surface_override_material(s, _hidden())
|
||||||
|
var hair_only := await _grab("race%d-hair_only" % race)
|
||||||
|
var hs := _lum_stats(hair_only)
|
||||||
|
m["hair_lum_p99"] = hs["p99"]
|
||||||
|
m["hair_bright_frac"] = hs["bright_frac"]
|
||||||
|
m["hair_mean_lum"] = hs["mean"]
|
||||||
|
var legacy := _legacy_hair_material(base_mats[hair_first])
|
||||||
|
for s in range(hair_first, total):
|
||||||
|
body.set_surface_override_material(s, legacy)
|
||||||
|
var ctrl := await _grab("race%d-hair_only-legacy_ctrl" % race)
|
||||||
|
var cs := _lum_stats(ctrl)
|
||||||
|
m["hair_lum_ctrl"] = cs["p99"]
|
||||||
|
m["hair_bright_frac_ctrl"] = cs["bright_frac"]
|
||||||
|
m["hair_mean_lum_ctrl"] = cs["mean"]
|
||||||
|
_restore(body, base_mats)
|
||||||
|
else:
|
||||||
|
m["hair_lum_p99"] = 0.0
|
||||||
|
m["hair_lum_ctrl"] = 0.0
|
||||||
|
m["hair_bright_frac"] = 0.0
|
||||||
|
m["hair_bright_frac_ctrl"] = 0.0
|
||||||
|
m["hair_mean_lum"] = 0.0
|
||||||
|
m["hair_mean_lum_ctrl"] = 0.0
|
||||||
|
|
||||||
|
pv.queue_free()
|
||||||
|
await process_frame
|
||||||
|
return m
|
||||||
|
|
||||||
|
# --- 断言 -----------------------------------------------------------------
|
||||||
|
|
||||||
|
func _check_absolute(race: int, m: Dictionary) -> void:
|
||||||
|
var gap: float = m["feet_gap_m"]
|
||||||
|
if not is_finite(gap) or absf(gap) > FEET_GAP_MAX:
|
||||||
|
_fail.append("race %d: feet_gap_m=%s exceeds %.3f" % [race, gap, FEET_GAP_MAX])
|
||||||
|
if int(m["silhouette_px"]) < SILHOUETTE_MIN:
|
||||||
|
_fail.append("race %d: silhouette_px=%d below %d" % [race, int(m["silhouette_px"]), SILHOUETTE_MIN])
|
||||||
|
if int(m["hair_surface_first"]) < 0:
|
||||||
|
return
|
||||||
|
var margin: float = float(m["hair_mean_lum_ctrl"]) - float(m["hair_mean_lum"])
|
||||||
|
if margin < HAIR_CTRL_MARGIN:
|
||||||
|
_fail.append(("race %d: legacy control only %.4f above production; " % [race, margin])
|
||||||
|
+ "hair_mean_lum no longer discriminates")
|
||||||
|
|
||||||
|
func _check_baseline(race: int, m: Dictionary, baseline: Dictionary) -> void:
|
||||||
|
var key := str(race)
|
||||||
|
if not baseline.has(key):
|
||||||
|
_fail.append("race %d: no baseline entry (run with --bless after reviewing the render)" % race)
|
||||||
|
return
|
||||||
|
var b: Dictionary = baseline[key]
|
||||||
|
var cover_delta: float = absf(float(m["head_cover"]) - float(b.get("head_cover", 0.0)))
|
||||||
|
if cover_delta > TOL_HEAD_COVER:
|
||||||
|
_fail.append("race %d: head_cover %.4f vs baseline %.4f (delta %.4f > %.3f)"
|
||||||
|
% [race, float(m["head_cover"]), float(b.get("head_cover", 0.0)), cover_delta, TOL_HEAD_COVER])
|
||||||
|
var base_px: float = float(b.get("silhouette_px", 0.0))
|
||||||
|
if base_px > 0.0:
|
||||||
|
var rel: float = absf(float(m["silhouette_px"]) - base_px) / base_px
|
||||||
|
if rel > TOL_SILHOUETTE:
|
||||||
|
_fail.append("race %d: silhouette_px %d vs baseline %d (%.1f%% > %.1f%%)"
|
||||||
|
% [race, int(m["silhouette_px"]), int(base_px), rel * 100.0, TOL_SILHOUETTE * 100.0])
|
||||||
|
var lum_delta: float = absf(float(m["hair_mean_lum"]) - float(b.get("hair_mean_lum", 0.0)))
|
||||||
|
if lum_delta > TOL_HAIR_LUM:
|
||||||
|
_fail.append("race %d: hair_mean_lum %.4f vs baseline %.4f (delta %.4f > %.4f)"
|
||||||
|
% [race, float(m["hair_mean_lum"]), float(b.get("hair_mean_lum", 0.0)),
|
||||||
|
lum_delta, TOL_HAIR_LUM])
|
||||||
|
|
||||||
|
# --- 像素统计 -------------------------------------------------------------
|
||||||
|
# get_pixel 逐点调用在 512x512 上太慢,直接读 RGBA8 字节。
|
||||||
|
|
||||||
|
func _fg_mask(img: Image) -> PackedByteArray:
|
||||||
|
var d := img.get_data()
|
||||||
|
var n := img.get_width() * img.get_height()
|
||||||
|
var mask := PackedByteArray()
|
||||||
|
mask.resize(n)
|
||||||
|
for i in n:
|
||||||
|
var o := i * 4
|
||||||
|
# 背景是纯洋红;模型不会出现 R 高 / G 近 0 / B 高 的组合
|
||||||
|
mask[i] = 0 if (d[o] > 240 and d[o + 1] < 24 and d[o + 2] > 240) else 1
|
||||||
|
return mask
|
||||||
|
|
||||||
|
func _count_fg(img: Image) -> int:
|
||||||
|
var mask := _fg_mask(img)
|
||||||
|
var c := 0
|
||||||
|
for v in mask:
|
||||||
|
c += v
|
||||||
|
return c
|
||||||
|
|
||||||
|
# 「发型盖住了脑袋多少」:只数 no_hair 里属于人体的像素中被 full 改写的那些,
|
||||||
|
# 不含发型溢出到背景的部分(那属于轮廓,不是遮挡)。
|
||||||
|
func _count_diff(a: Image, b: Image) -> int:
|
||||||
|
var da := a.get_data()
|
||||||
|
var db := b.get_data()
|
||||||
|
var n := mini(da.size(), db.size()) / 4
|
||||||
|
var c := 0
|
||||||
|
for i in n:
|
||||||
|
var o := i * 4
|
||||||
|
var br := int(db[o]); var bg := int(db[o + 1]); var bb := int(db[o + 2])
|
||||||
|
if br > 240 and bg < 24 and bb > 240:
|
||||||
|
continue # no_hair 里是背景 —— 不算遮挡
|
||||||
|
if absi(int(da[o]) - br) > 12 or absi(int(da[o + 1]) - bg) > 12 \
|
||||||
|
or absi(int(da[o + 2]) - bb) > 12:
|
||||||
|
c += 1
|
||||||
|
return c
|
||||||
|
|
||||||
|
# 前景像素亮度统计(256 桶直方图)。返回 {p99, bright_frac, mean}。
|
||||||
|
# 判定用 mean:它对「整片发型被额外高光抬亮」最敏感,且不受发色基准影响
|
||||||
|
# (基准差异由按职业的基线吸收)。p99/bright_frac 只写进报告备查。
|
||||||
|
func _lum_stats(img: Image) -> Dictionary:
|
||||||
|
var d := img.get_data()
|
||||||
|
var n := img.get_width() * img.get_height()
|
||||||
|
var hist := PackedInt32Array()
|
||||||
|
hist.resize(256)
|
||||||
|
var total := 0
|
||||||
|
for i in n:
|
||||||
|
var o := i * 4
|
||||||
|
var r := int(d[o]); var g := int(d[o + 1]); var bl := int(d[o + 2])
|
||||||
|
if r > 240 and g < 24 and bl > 240:
|
||||||
|
continue
|
||||||
|
var lum := int((0.2126 * float(r) + 0.7152 * float(g) + 0.0722 * float(bl)))
|
||||||
|
hist[clampi(lum, 0, 255)] += 1
|
||||||
|
total += 1
|
||||||
|
if total == 0:
|
||||||
|
return {"p99": 0.0, "bright_frac": 0.0, "mean": 0.0}
|
||||||
|
var cut := int(float(total) * 0.99)
|
||||||
|
var acc := 0
|
||||||
|
var p99 := 1.0
|
||||||
|
for v in 256:
|
||||||
|
acc += hist[v]
|
||||||
|
if acc >= cut:
|
||||||
|
p99 = float(v) / 255.0
|
||||||
|
break
|
||||||
|
var bright := 0
|
||||||
|
var sum := 0
|
||||||
|
for v in 256:
|
||||||
|
sum += hist[v] * v
|
||||||
|
if v >= int(LUM_BRIGHT * 255.0):
|
||||||
|
bright += hist[v]
|
||||||
|
return {"p99": p99, "bright_frac": float(bright) / float(total),
|
||||||
|
"mean": float(sum) / float(total) / 255.0}
|
||||||
|
|
||||||
|
# --- 场景辅助 -------------------------------------------------------------
|
||||||
|
|
||||||
|
func _aim(cam: Camera3D, eye: Vector3, at: Vector3, fov: float) -> void:
|
||||||
|
cam.global_transform = Transform3D(Basis(), eye).looking_at(at, Vector3.UP)
|
||||||
|
cam.fov = fov
|
||||||
|
|
||||||
|
func _grab(name: String) -> Image:
|
||||||
|
await RenderingServer.frame_post_draw
|
||||||
|
await RenderingServer.frame_post_draw
|
||||||
|
var img := root.get_texture().get_image()
|
||||||
|
img.convert(Image.FORMAT_RGBA8)
|
||||||
|
img.save_png(_out.path_join(name + ".png"))
|
||||||
|
return img
|
||||||
|
|
||||||
|
func _light() -> void:
|
||||||
|
var sun := DirectionalLight3D.new()
|
||||||
|
sun.rotation_degrees = Vector3(-42, 155, 0)
|
||||||
|
sun.light_energy = 1.1
|
||||||
|
sun.set_layer_mask(0xFFFFF)
|
||||||
|
root.add_child(sun)
|
||||||
|
var env := WorldEnvironment.new()
|
||||||
|
var e := Environment.new()
|
||||||
|
e.background_mode = Environment.BG_COLOR
|
||||||
|
e.background_color = BG
|
||||||
|
e.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
|
||||||
|
e.ambient_light_color = Color(0.6, 0.62, 0.68)
|
||||||
|
e.ambient_light_energy = 0.8
|
||||||
|
env.environment = e
|
||||||
|
root.add_child(env)
|
||||||
|
|
||||||
|
func _hidden() -> StandardMaterial3D:
|
||||||
|
var m := StandardMaterial3D.new()
|
||||||
|
m.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
||||||
|
m.albedo_color = Color(0, 0, 0, 0)
|
||||||
|
m.no_depth_test = false
|
||||||
|
m.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||||
|
return m
|
||||||
|
|
||||||
|
func _restore(body: MeshInstance3D, base_mats: Array) -> void:
|
||||||
|
for s in base_mats.size():
|
||||||
|
body.set_surface_override_material(s, base_mats[s])
|
||||||
|
|
||||||
|
# 负对照:R2-02 修复前 _load_hair() 用的那一套。
|
||||||
|
func _legacy_hair_material(production: Material) -> StandardMaterial3D:
|
||||||
|
var m := StandardMaterial3D.new()
|
||||||
|
m.roughness = 1.0
|
||||||
|
m.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA_SCISSOR
|
||||||
|
m.alpha_scissor_threshold = 0.5
|
||||||
|
m.cull_mode = BaseMaterial3D.CULL_DISABLED
|
||||||
|
if production is ShaderMaterial:
|
||||||
|
var tex = (production as ShaderMaterial).get_shader_parameter("albedo_tex")
|
||||||
|
if tex != null:
|
||||||
|
m.albedo_texture = tex
|
||||||
|
return m
|
||||||
|
|
||||||
|
# 发型折在 body mesh 尾部;get_info() 报的 surfaces= 是身体自己的 surface 数。
|
||||||
|
func _hair_surface_first(pv: Node3D, body: MeshInstance3D, total: int) -> int:
|
||||||
|
if String(pv.model.get("hair_gr2")).is_empty():
|
||||||
|
return -1
|
||||||
|
var info := String(pv.model.call("get_info"))
|
||||||
|
var body_surfaces := -1
|
||||||
|
for token in info.split(" ", false):
|
||||||
|
if token.begins_with("surfaces="):
|
||||||
|
body_surfaces = int(token.substr(9))
|
||||||
|
if body_surfaces <= 0 or body_surfaces >= total:
|
||||||
|
return -1
|
||||||
|
return body_surfaces
|
||||||
|
|
||||||
|
# --- 基线读写 -------------------------------------------------------------
|
||||||
|
|
||||||
|
func _load_baseline() -> Dictionary:
|
||||||
|
if not FileAccess.file_exists(BASELINE):
|
||||||
|
return {}
|
||||||
|
var txt := FileAccess.get_file_as_string(BASELINE)
|
||||||
|
var parsed = JSON.parse_string(txt)
|
||||||
|
return parsed.get("races", {}) if parsed is Dictionary else {}
|
||||||
|
|
||||||
|
func _write_baseline(results: Dictionary) -> void:
|
||||||
|
var slim := {}
|
||||||
|
for k in results:
|
||||||
|
var m: Dictionary = results[k]
|
||||||
|
slim[k] = {"body_gr2": String(m["body_gr2"]).get_file(),
|
||||||
|
"hair_gr2": String(m["hair_gr2"]).get_file(),
|
||||||
|
"head_cover": m["head_cover"], "silhouette_px": m["silhouette_px"],
|
||||||
|
"head_px": m["head_px"], "hair_mean_lum": m["hair_mean_lum"],
|
||||||
|
"hair_mean_lum_ctrl": m["hair_mean_lum_ctrl"],
|
||||||
|
"hair_lum_p99": m["hair_lum_p99"], "hair_bright_frac": m["hair_bright_frac"]}
|
||||||
|
DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(BASELINE).get_base_dir())
|
||||||
|
var f := FileAccess.open(BASELINE, FileAccess.WRITE)
|
||||||
|
f.store_string(JSON.stringify({
|
||||||
|
"note": "godot --path project --script model_render_test.gd -- --bless",
|
||||||
|
"engine": Engine.get_version_info()["string"],
|
||||||
|
"races": slim}, "\t"))
|
||||||
|
f.close()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://fb0dr5ulbc3g
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
# net_trace —— 主角 vid 生命周期取证。
|
||||||
|
#
|
||||||
|
# 实机进游戏后画面全灰、主角是蓝胶囊:GAMESCENE_DIAG 显示地图正常装载,
|
||||||
|
# 但 main_vid=0、主角实体为空 —— 主角在 setup() 之后被从 EntityStore 里
|
||||||
|
# 摘掉了。C++ 侧能把 m_main_vid 清零的路只有三条(mut_despawn /
|
||||||
|
# m2dev GC_CHARACTER_DEL / reset_for_map_change),前两条会发
|
||||||
|
# entity_despawned 信号,第三条会清空 m_changes、悄悄归零。
|
||||||
|
# 这个节点每帧轮询 main_vid 并记录信号,于是「哪条路」直接可读:
|
||||||
|
# MAIN_LOST reason=despawn → GC_CHARACTER_DEL 命中了主角
|
||||||
|
# MAIN_LOST reason=silent → reset_for_map_change(warp)或别的静默清零
|
||||||
|
#
|
||||||
|
# 常驻但极廉价(一次 get_main_vid + 一个整数比较);只在事件发生时打印。
|
||||||
|
extends Node
|
||||||
|
|
||||||
|
const RING := 24
|
||||||
|
|
||||||
|
var _client: Node = null
|
||||||
|
var _main_vid := 0
|
||||||
|
var _t0 := 0
|
||||||
|
var _ring: Array[String] = []
|
||||||
|
var _verbose := false
|
||||||
|
|
||||||
|
func setup(client: Node) -> void:
|
||||||
|
_client = client
|
||||||
|
_t0 = Time.get_ticks_msec()
|
||||||
|
# 主角在进场那几秒会被服务器 DEL+ADD 若干次(参考端同样如此),逐条打太吵,
|
||||||
|
# 默认只报异常;MT_NETTRACE=1 才打全量。
|
||||||
|
_verbose = OS.get_environment("MT_NETTRACE") == "1"
|
||||||
|
for sig in ["entity_main_set", "entity_despawned", "entity_spawned",
|
||||||
|
"entered_game", "disconnected", "stage_changed", "phase_changed",
|
||||||
|
"world_reset", "warp"]:
|
||||||
|
if client.has_signal(sig):
|
||||||
|
client.connect(sig, Callable(self, "_on_" + sig))
|
||||||
|
|
||||||
|
func _ms() -> int:
|
||||||
|
return Time.get_ticks_msec() - _t0
|
||||||
|
|
||||||
|
func _note(line: String) -> void:
|
||||||
|
var s := "+%dms %s" % [_ms(), line]
|
||||||
|
_ring.append(s)
|
||||||
|
if _ring.size() > RING:
|
||||||
|
_ring.pop_front()
|
||||||
|
|
||||||
|
func _dump(head: String) -> void:
|
||||||
|
printerr("NETTRACE: ", head)
|
||||||
|
for s in _ring:
|
||||||
|
printerr("NETTRACE: ", s)
|
||||||
|
|
||||||
|
func _on_entity_main_set(vid: int) -> void:
|
||||||
|
_note("main_set vid=%d" % vid)
|
||||||
|
if _verbose:
|
||||||
|
printerr("NETTRACE: MAIN_SET vid=%d t=+%dms" % [vid, _ms()])
|
||||||
|
|
||||||
|
func _on_entity_spawned(entity: Dictionary) -> void:
|
||||||
|
var vid := int(entity.get("vid", 0))
|
||||||
|
if vid == _main_vid or bool(entity.get("is_main", false)):
|
||||||
|
_note("spawn vid=%d race=%s is_main=%s" % [vid,
|
||||||
|
str(entity.get("race", null)), str(entity.get("is_main", false))])
|
||||||
|
|
||||||
|
func _on_entity_despawned(vid: int) -> void:
|
||||||
|
_note("despawn vid=%d" % vid)
|
||||||
|
if vid == _main_vid:
|
||||||
|
_note("*** despawn hit MAIN vid=%d" % vid)
|
||||||
|
|
||||||
|
func _on_entered_game() -> void:
|
||||||
|
_note("entered_game")
|
||||||
|
|
||||||
|
func _on_disconnected(reason: String) -> void:
|
||||||
|
_note("disconnected " + reason)
|
||||||
|
printerr("NETTRACE: DISCONNECTED %s t=+%dms" % [reason, _ms()])
|
||||||
|
|
||||||
|
func _on_world_reset() -> void:
|
||||||
|
_note("world_reset (reset_for_map_change)")
|
||||||
|
|
||||||
|
func _on_warp(pos: Vector3, _reconnect: bool) -> void:
|
||||||
|
_note("warp " + str(pos))
|
||||||
|
|
||||||
|
func _on_stage_changed(stage: String) -> void:
|
||||||
|
_note("stage=" + stage)
|
||||||
|
|
||||||
|
func _on_phase_changed(phase: String) -> void:
|
||||||
|
_note("phase=" + phase)
|
||||||
|
|
||||||
|
func _process(_dt: float) -> void:
|
||||||
|
if _client == null or not _client.has_method("get_main_vid"):
|
||||||
|
return
|
||||||
|
var vid := int(_client.get_main_vid())
|
||||||
|
if vid == _main_vid:
|
||||||
|
return
|
||||||
|
if vid != 0 and _main_vid == 0:
|
||||||
|
_main_vid = vid
|
||||||
|
_note("main_vid 0 -> %d" % vid)
|
||||||
|
return
|
||||||
|
# 丢主角:把最近的事件环一起打出来,能读出是 despawn 还是静默清零。
|
||||||
|
var was := _main_vid
|
||||||
|
_main_vid = vid
|
||||||
|
var reason := "silent"
|
||||||
|
for s in _ring:
|
||||||
|
if s.ends_with("despawn hit MAIN vid=%d" % was):
|
||||||
|
reason = "despawn"
|
||||||
|
_dump("MAIN_LOST was=%d now=%d reason=%s t=+%dms" % [was, vid, reason, _ms()])
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://bln1u7gitn4jw
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
# package_render_test —— 在**导出包内部**跑的渲染自检。
|
||||||
|
#
|
||||||
|
# MT_TEST_MODE=render build/export/mtgodot-poc.app/Contents/MacOS/mtgodot-poc
|
||||||
|
#
|
||||||
|
# 存在的理由:导出模板不接受 `--script`,所以 model_render_test.gd /
|
||||||
|
# gamescene_visual_test.gd 这类 `extends SceneTree` 的离线回归**只能在编辑器里跑**。
|
||||||
|
# 编辑器全绿而实机进游戏是「灰紫虚空 + 蓝胶囊」时,两边差异无从定位——包内没有
|
||||||
|
# 任何不依赖服务器的入口。本脚本就是那个入口:由 client_main.gd 在
|
||||||
|
# MT_TEST_MODE=render 时挂到主场景下,走真实 GameScene + 真实 Metin2World +
|
||||||
|
# 真实 PlayerView,不连服务器、不写账号,跑完把报告和截图落到 user:// 并退出。
|
||||||
|
#
|
||||||
|
# 覆盖范围:包内的资源解析、GDExtension 注册、地图装载、角色装配、落地高度。
|
||||||
|
# **不覆盖**:网络驱动的进场时序、40250 像素比对、性能。
|
||||||
|
extends Node
|
||||||
|
|
||||||
|
const GameScene = preload("res://game_scene.gd")
|
||||||
|
const Fixtures = preload("res://gamescene_test.gd")
|
||||||
|
|
||||||
|
const MAP := "OutdoorA1/metin2_map_a1"
|
||||||
|
# 与 gamescene_visual_test.gd 同一组坐标和阈值,两边结论才可比。
|
||||||
|
const SERVER_CM := Vector2(454600, 934000)
|
||||||
|
const FEET_GAP_MAX := 0.03
|
||||||
|
const SILHOUETTE_MIN := 3000
|
||||||
|
|
||||||
|
var _fail: Array[String] = []
|
||||||
|
var _out := ""
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
_out = OS.get_environment("MT_RENDER_OUTPUT")
|
||||||
|
if _out.is_empty():
|
||||||
|
_out = ProjectSettings.globalize_path("user://render-check")
|
||||||
|
DirAccess.make_dir_recursive_absolute(_out)
|
||||||
|
call_deferred("_run")
|
||||||
|
|
||||||
|
func _run() -> void:
|
||||||
|
var report := {"env": _env(), "scope":
|
||||||
|
"in-package offline check: real GameScene + Metin2World + PlayerView, no network"}
|
||||||
|
|
||||||
|
# 1) 扩展必须注册 —— 少了它 GameScene 会退回蓝胶囊 + 无地形灰底,
|
||||||
|
# 而这两件事在画面上和「资源没找到」长得一模一样,必须先分开。
|
||||||
|
for cls in ["Metin2Model", "Metin2World", "Metin2AnimPlayer"]:
|
||||||
|
if not ClassDB.class_exists(cls):
|
||||||
|
_fail.append("GDExtension 类未注册:%s" % cls)
|
||||||
|
# 2) 资源根必须落在包内(或 MT_ASSETS 指定处)且真的有地图和角色
|
||||||
|
var root: String = report["env"]["asset_root"]
|
||||||
|
for probe in [MAP, "PC/ymir work/pc/warrior", "asset_index.txt"]:
|
||||||
|
var p := root.path_join(probe)
|
||||||
|
if not (FileAccess.file_exists(p) or DirAccess.dir_exists_absolute(p)):
|
||||||
|
_fail.append("资源缺失:%s" % p)
|
||||||
|
|
||||||
|
if _fail.is_empty():
|
||||||
|
report["scene"] = await _build_and_measure()
|
||||||
|
else:
|
||||||
|
report["scene"] = {"skipped": "前置检查已失败,不再装场景"}
|
||||||
|
|
||||||
|
report["passed"] = _fail.is_empty()
|
||||||
|
report["failures"] = _fail
|
||||||
|
var f := FileAccess.open(_out.path_join("report.json"), FileAccess.WRITE)
|
||||||
|
f.store_string(JSON.stringify(report, "\t"))
|
||||||
|
f.close()
|
||||||
|
print("PKGRENDER: ", _out)
|
||||||
|
print(JSON.stringify(report))
|
||||||
|
for m in _fail:
|
||||||
|
printerr("PKGRENDER FAIL: ", m)
|
||||||
|
print("PKGRENDER: ", "PASS" if _fail.is_empty() else "FAIL")
|
||||||
|
get_tree().quit(0 if _fail.is_empty() else 1)
|
||||||
|
|
||||||
|
# 包内和开发树的差异几乎都藏在这几个值里,先无条件记下来。
|
||||||
|
func _env() -> Dictionary:
|
||||||
|
var cands := {}
|
||||||
|
for c in _asset_candidates():
|
||||||
|
cands[c] = DirAccess.dir_exists_absolute(c)
|
||||||
|
return {
|
||||||
|
"engine": Engine.get_version_info()["string"],
|
||||||
|
"executable": OS.get_executable_path(),
|
||||||
|
"res_globalized": ProjectSettings.globalize_path("res://"),
|
||||||
|
"user_dir": OS.get_user_data_dir(),
|
||||||
|
"features": {"template": OS.has_feature("template"), "debug": OS.has_feature("debug"),
|
||||||
|
"release": OS.has_feature("release"), "editor": OS.has_feature("editor")},
|
||||||
|
"MT_ASSETS": OS.get_environment("MT_ASSETS"),
|
||||||
|
"MT_PROTOCOL": OS.get_environment("MT_PROTOCOL"),
|
||||||
|
"asset_candidates": cands,
|
||||||
|
"asset_root": AssetRoot.path(),
|
||||||
|
"asset_root_available": AssetRoot.available(),
|
||||||
|
"classes": {"Metin2Model": ClassDB.class_exists("Metin2Model"),
|
||||||
|
"Metin2World": ClassDB.class_exists("Metin2World"),
|
||||||
|
"Metin2AnimPlayer": ClassDB.class_exists("Metin2AnimPlayer")},
|
||||||
|
"rendering_driver": DisplayServer.get_name(),
|
||||||
|
"viewport": str(get_window().size),
|
||||||
|
}
|
||||||
|
|
||||||
|
# AssetRoot._candidates() 是私有的;这里复述一遍只为把每个候选的命中情况写进报告。
|
||||||
|
func _asset_candidates() -> Array:
|
||||||
|
var out: Array = []
|
||||||
|
var env := OS.get_environment("MT_ASSETS")
|
||||||
|
if env != "":
|
||||||
|
out.append(env)
|
||||||
|
if FileAccess.file_exists("res://assets/asset_index.txt"):
|
||||||
|
out.append("res://assets")
|
||||||
|
out.append(ProjectSettings.globalize_path("res://../assets"))
|
||||||
|
var exe := OS.get_executable_path()
|
||||||
|
if exe != "":
|
||||||
|
var d := exe.get_base_dir()
|
||||||
|
out.append(d.path_join("../Resources/assets").simplify_path())
|
||||||
|
out.append(d.path_join("../../../assets").simplify_path())
|
||||||
|
out.append(d.path_join("assets"))
|
||||||
|
return out
|
||||||
|
|
||||||
|
func _build_and_measure() -> Dictionary:
|
||||||
|
var fc := Fixtures.FakeClient.new()
|
||||||
|
add_child(fc)
|
||||||
|
var scene := GameScene.new()
|
||||||
|
add_child(scene)
|
||||||
|
await scene.setup(fc, AssetRoot.path(), MAP)
|
||||||
|
fc.main = 1000
|
||||||
|
fc.ents[1000] = {"vid": 1000, "name": "PkgRenderTest", "race": 0,
|
||||||
|
"pos": Vector3(SERVER_CM.x * 0.01, 0, -SERVER_CM.y * 0.01),
|
||||||
|
"pos_cm": SERVER_CM, "is_main": true, "angle_deg": 0.0,
|
||||||
|
"func": 0, "moving": false, "hp": 100, "max_hp": 100, "dead": false}
|
||||||
|
for i in 30:
|
||||||
|
await get_tree().process_frame
|
||||||
|
|
||||||
|
var expected := MapCoord.to_world(fc.ents[1000].pos)
|
||||||
|
expected.y = scene.world.sample_height(expected.x, expected.z) if scene.world else 0.0
|
||||||
|
var surfaces := 0
|
||||||
|
for mi in scene.player.find_children("*", "MeshInstance3D", true, false):
|
||||||
|
if mi.mesh:
|
||||||
|
surfaces += mi.mesh.get_surface_count()
|
||||||
|
if scene._model_built and scene.player.anim:
|
||||||
|
scene.player.anim.set_time(0.5)
|
||||||
|
scene.player.anim.set_process(false)
|
||||||
|
await get_tree().process_frame
|
||||||
|
var feet_y := INF
|
||||||
|
for mi in scene.player.find_children("*", "MeshInstance3D", true, false):
|
||||||
|
if mi.mesh:
|
||||||
|
feet_y = minf(feet_y, (mi.global_transform * mi.get_aabb()).position.y)
|
||||||
|
var feet_gap := feet_y - expected.y
|
||||||
|
|
||||||
|
if not scene._model_built:
|
||||||
|
_fail.append("主角模型没装出来(仍是 _make_placeholder_player 的蓝胶囊)")
|
||||||
|
if not scene._map_loaded():
|
||||||
|
_fail.append("地图没装出来(灰底虚空)")
|
||||||
|
if surfaces == 0:
|
||||||
|
_fail.append("玩家节点下 0 个 surface")
|
||||||
|
if not is_finite(feet_gap) or absf(feet_gap) > FEET_GAP_MAX:
|
||||||
|
_fail.append("feet_gap=%s 超出 %.3f" % [feet_gap, FEET_GAP_MAX])
|
||||||
|
|
||||||
|
var shot := "SKIPPED_HEADLESS"
|
||||||
|
if DisplayServer.get_name() != "headless":
|
||||||
|
scene.cam.snap_to_target()
|
||||||
|
await RenderingServer.frame_post_draw
|
||||||
|
await RenderingServer.frame_post_draw
|
||||||
|
var img := get_window().get_texture().get_image()
|
||||||
|
shot = "game.png" if img.save_png(_out.path_join("game.png")) == OK else "SAVE_FAILED"
|
||||||
|
|
||||||
|
var d := {"map": scene.map_path, "model_built": scene._model_built,
|
||||||
|
"map_loaded": scene._map_loaded(), "surfaces": surfaces,
|
||||||
|
"world_position": str(scene.player.position), "expected": str(expected),
|
||||||
|
"feet_y": feet_y, "feet_gap": feet_gap, "feet_gap_max": FEET_GAP_MAX,
|
||||||
|
"map_report": scene.world.get_load_report() if scene.world else {},
|
||||||
|
"camera_transform": str(scene.cam.global_transform), "screenshot": shot}
|
||||||
|
scene.queue_free()
|
||||||
|
fc.queue_free()
|
||||||
|
await get_tree().process_frame
|
||||||
|
return d
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://rm3i2avq75g
|
||||||
@@ -17,9 +17,57 @@ godot --path project --script gamescene_visual_test.gd -- --c1
|
|||||||
- `gamescene_test.gd` 使用不存在的地图,预期有加载错误日志;它只验证无地图装配,不能当作画面正常证据。
|
- `gamescene_test.gd` 使用不存在的地图,预期有加载错误日志;它只验证无地图装配,不能当作画面正常证据。
|
||||||
- `gamescene_visual_test.gd` 使用真实资产和实际 GameScene,通过 FakeClient 延迟提供 race=0 主角数据,**不发**主角信号。无账号和服务器写入。
|
- `gamescene_visual_test.gd` 使用真实资产和实际 GameScene,通过 FakeClient 延迟提供 race=0 主角数据,**不发**主角信号。无账号和服务器写入。
|
||||||
- 默认先加载 A1;`--c1` 随后提供 C1 服务器位置,用来验证晚到坐标能纠正地图选择。
|
- 默认先加载 A1;`--c1` 随后提供 C1 服务器位置,用来验证晚到坐标能纠正地图选择。
|
||||||
|
- `gamescene_visual_test.gd` 除位置/地图/表面数外还断言 `feet_gap`:把动画冻结在 0.5 s
|
||||||
|
后取全部 MeshInstance3D 世界包围盒的最低点,减去该处地形高度,要求 |gap| ≤ 0.03 m
|
||||||
|
(R2-01 悬空修复的数值守卫;A1 与 C1 实测均为 0.001373 m)。报告里同时记录
|
||||||
|
`feet_y` / `feet_gap` / `feet_gap_max`。
|
||||||
- 视觉用例输出 `build/rendering/gamescene-<timestamp>/game.png` 与 `report.json`。可用环境变量 `MT_RENDER_OUTPUT` 指定独立目录;复用路径会覆盖同名报告,建议每次新目录。
|
- 视觉用例输出 `build/rendering/gamescene-<timestamp>/game.png` 与 `report.json`。可用环境变量 `MT_RENDER_OUTPUT` 指定独立目录;复用路径会覆盖同名报告,建议每次新目录。
|
||||||
- 报告包含位置、相机、地图加载计数、模型表面数、引擎、关键脚本/扩展/模型路径哈希。资产依赖尚未穷举;这只是 R0 部分基线。
|
- 报告包含位置、相机、地图加载计数、模型表面数、引擎、关键脚本/扩展/模型路径哈希。资产依赖尚未穷举;这只是 R0 部分基线。
|
||||||
- 截图尺寸为 1280×720,角色待机固定在 0.5 秒。地形/环境动态因素尚未完全冻结,因此暂不设置像素误差硬阈值。
|
- 截图尺寸为 1280×720,角色待机固定在 0.5 秒。地形/环境动态因素尚未完全冻结,因此暂不设置像素误差硬阈值。
|
||||||
- 测试不配置学习技能,截图中的空快捷槽不代表技能图标验证成功。
|
- 测试不配置学习技能,截图中的空快捷槽不代表技能图标验证成功。
|
||||||
|
|
||||||
|
## 不启动游戏的模型渲染回归(model_render_test.gd)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
godot --path project --script model_render_test.gd # 八职业,判定
|
||||||
|
godot --path project --script model_render_test.gd -- --race 0
|
||||||
|
godot --path project --script model_render_test.gd -- --bless # 重建基线
|
||||||
|
```
|
||||||
|
|
||||||
|
无地图、无网络、无服务器:直接用 `ui/player_view.gd` 离线装配一个角色,`wait`
|
||||||
|
动画冻结在 0.5 s,固定灯光(平行光 `(-42,155,0)` energy 1.1 + 环境色
|
||||||
|
`(0.6,0.62,0.68)` energy 0.8)、洋红背景、512×512。与 `char_bench_test.gd` 的区别
|
||||||
|
是它对像素算指标并断言,退出码即结论;bench 仍然只产图供人看。
|
||||||
|
|
||||||
|
指标(每个 race)与判定方式:
|
||||||
|
|
||||||
|
| 指标 | 含义 | 判定 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `feet_gap_m` | 最低蒙皮顶点到 y=0 | 绝对,\|gap\| ≤ 0.03 m(实测 −0.0007…0.0030) |
|
||||||
|
| `silhouette_px` | 正面剪影像素数 | 绝对 ≥ 3000;且对基线 ±3% |
|
||||||
|
| `head_cover` | 近景机位下发型盖住头部像素的比例(只统计 no_hair 里属于人体的像素) | 对基线 ±0.03 |
|
||||||
|
| `hair_mean_lum` | 只画发型时发型像素平均亮度 | 对基线 ±0.006 |
|
||||||
|
| `hair_mean_lum_ctrl` | 同上,但换回 R2-02 修复前的 `StandardMaterial3D(roughness=1)` | 负对照:必须比生产值高 ≥ 0.008 |
|
||||||
|
| `hair_lum_p99` / `hair_bright_frac` | 亮度 99 分位 / 超过 0.20 的像素占比 | 仅记录,不判定 |
|
||||||
|
|
||||||
|
为什么用平均亮度而不是「有多少像素炸白」:发色本身差别极大(武士近黑
|
||||||
|
`mean 0.061`,修罗银白 `mean 0.260`),任何跨职业的绝对亮度上限都不成立。平均
|
||||||
|
亮度对「整片发型被额外高光抬亮」敏感,基准差异由按职业记录的基线吸收。
|
||||||
|
|
||||||
|
负对照不是摆设:它每次都真的用旧材质再渲一遍,保证这条指标现在仍分得开好坏
|
||||||
|
(例如有人把灯光调暗到两边都测不出高光时会直接失败)。八职业实测旧材质相对
|
||||||
|
新材质的抬升是 +0.0109 … +0.0371,最小值 0.0109 给阈值 0.006/0.008 留了余量。
|
||||||
|
|
||||||
|
已验证它确实能抓到目标缺陷:把 `Metin2Model::_load_hair()` 临时改回
|
||||||
|
`StandardMaterial3D(roughness=1)` 重新编译后,**八个职业全部失败**
|
||||||
|
(`hair_mean_lum` 偏离基线 0.0109…0.0371);改回 `mtgodot::make_material` 后
|
||||||
|
`PASS`。
|
||||||
|
|
||||||
|
基线在 `test/rendering/model_baseline.json`,含引擎版本。改模型、改发型材质或改
|
||||||
|
这套灯光都会让基线失效,需要人工看过渲染结果再 `--bless`,并在
|
||||||
|
`docs/RENDERING-REPAIR-PLAN.md` 的执行记录里写清重建原因。
|
||||||
|
|
||||||
|
覆盖边界:只有离线单角色装配。不含地图、地形接触、真实光照昼夜、装备叠加、
|
||||||
|
40250 像素比对,也不能替代 `gamescene_visual_test.gd` 与导出包验收。
|
||||||
|
|
||||||
已知后续:脸部/脚底近景和区域差异器、完整资产哈希、四职业 GR2/GLB 对照、技能状态与图标用例、真实联机及普通 macOS `.app` 验收。
|
已知后续:脸部/脚底近景和区域差异器、完整资产哈希、四职业 GR2/GLB 对照、技能状态与图标用例、真实联机及普通 macOS `.app` 验收。
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
{
|
||||||
|
"engine": "4.7.1-stable (official)",
|
||||||
|
"note": "godot --path project --script model_render_test.gd -- --bless",
|
||||||
|
"races": {
|
||||||
|
"0": {
|
||||||
|
"body_gr2": "warrior_novice.gr2",
|
||||||
|
"hair_bright_frac": 0.0224071972085593,
|
||||||
|
"hair_gr2": "hair_1_1.gr2",
|
||||||
|
"hair_lum_p99": 0.254901960784314,
|
||||||
|
"hair_mean_lum": 0.0607960189162477,
|
||||||
|
"hair_mean_lum_ctrl": 0.097848223242717,
|
||||||
|
"head_cover": 0.324674209407673,
|
||||||
|
"head_px": 46579,
|
||||||
|
"silhouette_px": 35073
|
||||||
|
},
|
||||||
|
"1": {
|
||||||
|
"body_gr2": "assassin_novice.gr2",
|
||||||
|
"hair_bright_frac": 0.316447908121411,
|
||||||
|
"hair_gr2": "hair_1_1.gr2",
|
||||||
|
"hair_lum_p99": 0.349019607843137,
|
||||||
|
"hair_mean_lum": 0.184415208398222,
|
||||||
|
"hair_mean_lum_ctrl": 0.195338585878083,
|
||||||
|
"head_cover": 0.244265393733581,
|
||||||
|
"head_px": 62428,
|
||||||
|
"silhouette_px": 36052
|
||||||
|
},
|
||||||
|
"2": {
|
||||||
|
"body_gr2": "sura_novice.gr2",
|
||||||
|
"hair_bright_frac": 0.834898912836162,
|
||||||
|
"hair_gr2": "hair_1_1.gr2",
|
||||||
|
"hair_lum_p99": 0.435294117647059,
|
||||||
|
"hair_mean_lum": 0.260437632997124,
|
||||||
|
"hair_mean_lum_ctrl": 0.281076636261981,
|
||||||
|
"head_cover": 0.0281083316817606,
|
||||||
|
"head_px": 116051,
|
||||||
|
"silhouette_px": 39682
|
||||||
|
},
|
||||||
|
"3": {
|
||||||
|
"body_gr2": "shaman_novice.gr2",
|
||||||
|
"hair_bright_frac": 0.122538024274082,
|
||||||
|
"hair_gr2": "hair_1_1.gr2",
|
||||||
|
"hair_lum_p99": 0.32156862745098,
|
||||||
|
"hair_mean_lum": 0.134457749300365,
|
||||||
|
"hair_mean_lum_ctrl": 0.166435011552631,
|
||||||
|
"head_cover": 0.295606972434941,
|
||||||
|
"head_px": 60983,
|
||||||
|
"silhouette_px": 36257
|
||||||
|
},
|
||||||
|
"4": {
|
||||||
|
"body_gr2": "warrior_novice.gr2",
|
||||||
|
"hair_bright_frac": 0.0294142102856193,
|
||||||
|
"hair_gr2": "hair_1_1.gr2",
|
||||||
|
"hair_lum_p99": 0.23921568627451,
|
||||||
|
"hair_mean_lum": 0.0852886966119754,
|
||||||
|
"hair_mean_lum_ctrl": 0.107547303626061,
|
||||||
|
"head_cover": 0.190623521275434,
|
||||||
|
"head_px": 27473,
|
||||||
|
"silhouette_px": 27104
|
||||||
|
},
|
||||||
|
"5": {
|
||||||
|
"body_gr2": "assassin_novice.gr2",
|
||||||
|
"hair_bright_frac": 0.0384454367848172,
|
||||||
|
"hair_gr2": "hair_1_1.gr2",
|
||||||
|
"hair_lum_p99": 0.270588235294118,
|
||||||
|
"hair_mean_lum": 0.0734998604521351,
|
||||||
|
"hair_mean_lum_ctrl": 0.0947519741918537,
|
||||||
|
"head_cover": 0.25690174015439,
|
||||||
|
"head_px": 30572,
|
||||||
|
"silhouette_px": 24776
|
||||||
|
},
|
||||||
|
"6": {
|
||||||
|
"body_gr2": "sura_novice.gr2",
|
||||||
|
"hair_bright_frac": 0.905465795806119,
|
||||||
|
"hair_gr2": "hair_1_1.gr2",
|
||||||
|
"hair_lum_p99": 0.427450980392157,
|
||||||
|
"hair_mean_lum": 0.277393012894398,
|
||||||
|
"hair_mean_lum_ctrl": 0.298905357949299,
|
||||||
|
"head_cover": 0.617005584237699,
|
||||||
|
"head_px": 37964,
|
||||||
|
"silhouette_px": 30280
|
||||||
|
},
|
||||||
|
"7": {
|
||||||
|
"body_gr2": "shaman_novice.gr2",
|
||||||
|
"hair_bright_frac": 0.0142828216749996,
|
||||||
|
"hair_gr2": "hair_1_1.gr2",
|
||||||
|
"hair_lum_p99": 0.219607843137255,
|
||||||
|
"hair_mean_lum": 0.0624432979584058,
|
||||||
|
"hair_mean_lum_ctrl": 0.0774457732565792,
|
||||||
|
"head_cover": 0.715665995318709,
|
||||||
|
"head_px": 36742,
|
||||||
|
"silhouette_px": 21953
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user