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

Networked client on the existing Godot 4.7 + libgr2 renderer:
- net: m2dev wire protocol (libsodium KX + XChaCha20), auth/select/game
  phases, EntityStore world model, ~all GC/CG headers. char create/delete,
  private shop / mall / cube, SHOP_GC_START_EX, guild, party (+ CG_PARTY_SET_STATE),
  quests, dragon soul, refine, safebox, exchange.
- UI: in-game windows migrated 1:1 from the reference uiscript/root .py —
  char status (/stat), inventory+equipment, select-item ([SELECT_ITEM] quest
  token), system-option + game-option + ESC system menu, private-shop 39-grid,
  party info board, shop tabs, atlas, minimap, quickbar, chat, …
- EterGrnLib polish: GR2 material blend/two-sided, LOD crossfade, motion-event
  dispatch, contact shadow, ray-AABB picking, weapon grip pre-transform.

Portable asset IO (A1) — all extension/libgr2/formats/mtproto reads routed
through godot::FileAccess (res:// PCK works on iOS/Android); standalone-lib
*_path() kept for the non-Godot CTests. AssetResolver + PropertyRegistry
switched to a baked index (bake_asset_index.gd) instead of std::filesystem.

Mobile builds: build-{android,ios}.sh, export-android.sh, pack-assets.sh,
gen-debug-keystore.sh. Assets ship as a zip mounted at runtime by
project/asset_pack.gd (adb push now; HTTP download is a drop-in later).

ctest 10/10, 34 GDScript suites, macOS/iOS/Android all build.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013EJxkHiNKS4kybHS3XKyAJ
This commit is contained in:
shen
2026-08-31 20:02:12 +09:00
co-authored by Claude Sonnet 5
parent f4917a2b3b
commit 47baf6c0c6
414 changed files with 69568 additions and 385 deletions
+37
View File
@@ -1,19 +1,56 @@
# Metin2 game assets (project/asset_root.gd). Committed to THIS self-hosted
# private repo for one-clone multi-device builds. Do not publish/redistribute
# — copyrighted Ymir art. MT_ASSETS env var overrides the location.
/assets/asset_index.txt # derived — regen: godot --headless --path project --script bake_asset_index.gd
# build # build
/build/ /build/
/build-ios/
/build-android/
/cmake-build-*/ /cmake-build-*/
compile_commands.json compile_commands.json
# godot # godot
/assets/**/*.import
/assets/**/*.uid
/project/.godot/ /project/.godot/
/project/bin/*.dylib /project/bin/*.dylib
/project/bin/*.dSYM/ /project/bin/*.dSYM/
/project/bin/*.so /project/bin/*.so
/project/bin/*.dll /project/bin/*.dll
/project/bin/*.wasm /project/bin/*.wasm
/project/bin/ios/
/project/bin/android/
# Android Gradle build template (~1.2 GB of regenerable Gradle output + Godot
# .aar). Recreate per machine:
# godot --headless --path project --install-android-build-template \
# --export-debug Android build/export/mtgodot-poc.apk
/project/android/
# misc # misc
.DS_Store .DS_Store
*.tmp *.tmp
*.o
*.a
# clangd # clangd
/.cache/ /.cache/
# oracle: cross-compiled exe + leaked Granny DLL (size / RAD IP — never commit;
# oracle/build-wine.sh rebuilds). See oracle/README.md.
/oracle/oracle.exe
/oracle/*.dll
/oracle/*.dylib
# vendored-lib test artifacts
/test/oracle_dumps/
/test/*.actual.png
/build-tools/
# libgr2 tool outputs
/fuzz-report.json
/test/m2-numeric.json.tmp
# compare.py render output
/test/compare-shots/
/test/compare-report.json
+6 -1
View File
@@ -1,4 +1,9 @@
[submodule "extension/godot-cpp"] [submodule "extension/godot-cpp"]
path = extension/godot-cpp path = extension/godot-cpp
url = https://github.com/godotengine/godot-cpp.git url = https://github.com/godotengine/godot-cpp.git
branch = master [submodule "extension/third_party/zstd"]
path = extension/third_party/zstd
url = https://github.com/facebook/zstd.git
[submodule "extension/third_party/libsodium-cmake"]
path = extension/third_party/libsodium-cmake
url = https://github.com/robinlinden/libsodium-cmake.git
+30 -3
View File
@@ -1,8 +1,14 @@
# mtgodot-poc — top level # mtgodot-poc — top level
# Phase 1 (macOS) scaffolding. See docs/GODOT-POC-PLAN.md §M0'. # Phase 1 (macOS) scaffolding. See docs/GODOT-POC-PLAN.md §M0'.
#
# libgr2 / formats / oracle / tools were vendored in from the retired xrender-poc
# repo (2026-08-29). libgr2 is the shared core (gr2 v6/v7 reader, oracle-verified);
# formats = .msa/.msm parsers (needed M2 T2.2/T2.3); tools = libgr2 validation.
cmake_minimum_required(VERSION 3.20) cmake_minimum_required(VERSION 3.20)
project(mtgodot_poc CXX C) project(mtgodot_poc CXX C)
include(CTest)
set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_C_STANDARD 17) set(CMAKE_C_STANDARD 17)
@@ -13,9 +19,30 @@ endif()
set(CMAKE_EXPORT_COMPILE_COMMANDS ON) set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
# Phase 1 is macOS-only; fail early anywhere else so nobody wastes time. option(MTGODOT_BUILD_EXTENSION "Build the Godot GDExtension (pulls in godot-cpp)" ON)
if(NOT APPLE) option(MTGODOT_BUILD_TOOLS "Build libgr2 validation tools (gr2dump/gr2fuzz/oracle_diff)" OFF)
message(FATAL_ERROR "mtgodot-poc Phase 1 targets macOS only (see docs/GODOT-POC-PLAN.md §00).")
# --- shared, cross-platform core (no godot-cpp, no third-party) ---
add_subdirectory(libgr2)
add_subdirectory(formats)
if(MTGODOT_BUILD_TOOLS)
add_subdirectory(tools)
endif() endif()
# --- the Godot extension ---
# Phase 1 was macOS-only; Phase 2 (BACKLOG F1/F2) adds iOS + Android. The three
# native deps are vendored (extension/third_party), so the only gate now is
# "is this a platform we've wired output naming + a toolchain for".
if(MTGODOT_BUILD_EXTENSION)
set(_mt_plat "${CMAKE_SYSTEM_NAME}")
if(_mt_plat STREQUAL "Darwin" OR _mt_plat STREQUAL "iOS" OR _mt_plat STREQUAL "Android")
message(STATUS "mtgodot extension: building for ${_mt_plat}")
else()
message(FATAL_ERROR
"MTGODOT_BUILD_EXTENSION: supported targets are macOS / iOS / Android "
"(got CMAKE_SYSTEM_NAME='${_mt_plat}'). See docs/PLATFORMS.md. "
"Use -DMTGODOT_BUILD_EXTENSION=OFF to build just libgr2/formats/tools.")
endif()
add_subdirectory(extension) add_subdirectory(extension)
endif()
+103 -51
View File
@@ -1,19 +1,28 @@
# mtgodot-poc # mtgodot-poc
「**Godot 4 + 自研资源 loader**」跨平台方案的渲染 Demoroute ①) 「**Godot 4 + 自研资源 loader**」跨平台方案的渲染 Demo。
用一个 C++ GDExtension 复用 [`xrender-poc`](../xrender-poc)`libgr2`,把 Metin2 的 用一个 C++ GDExtension 把 Metin2`.gr2` 骨骼资源渲染进 Godot,用 Godot 内置渲染器。
`.gr2` 骨骼资源渲染进 Godot,用 Godot 内置渲染器。
`xrender-poc`(自研引擎 + bgfx RHI)是**两条并行的跨平台基座候选**,共用 `libgr2` > **2026-08-29:并入 `xrender-poc`。** 原先与本仓库并行的 bgfx 自研引擎方案(`xrender-poc`
> 经中期评审([`docs/MIDREVIEW.md`](docs/MIDREVIEW.md)**停止开发**,其可复用部分——
> `libgr2`gr2 v6/v7 读取器,已对拍 Granny)、`formats`.msa/.msm)、`oracle`Granny 真值工具)、
> `tools`libgr2 校验工具)、`docs/reference`(格式逆向笔记)——已 **vendored 进本仓库**。
> bgfx 侧的渲染器 / 平台壳 / 第三方库全部弃用(Godot 取代)。bgfx demo 的参考截图留在
> `test/bgfx-reference/` 供交叉核对。`xrender-poc` 目录本身已删除(2026-08-30)。
判定分两阶段: 自用项目(内部研究,不对外发布),分两阶段推进
| 阶段 | 平台 | 里程碑 | 产出 | | 阶段 | 平台 | 里程碑 | 产出 |
|---|---|---|---| |---|---|---|---|
| **Phase 1**(当前) | macOS | M0' · M1 · M2 · M2.5 | 中期评审(不下 go/no-go | | **Phase 1**(当前) | macOS | M0' · M1 · M2 · M2.5 | 中期评审 |
| Phase 2 | iOS · Android | M3 | go / no-go | | Phase 2 | Android(一加 13 / Vulkan)· iOSiPhone 16 / Metal | M3 | 三设备各一次 bring-up + 性能留档 |
完整计划见 [`docs/GODOT-POC-PLAN.md`](docs/GODOT-POC-PLAN.md) 自用、不对外发布,**无正式 go/no-go 门禁**;三台目标设备都是现代硬件,**全程不涉及 GLES3 / Compatibility 渲染器**,不做中低端 / 老机器
完整计划见 [`docs/GODOT-POC-PLAN.md`](docs/GODOT-POC-PLAN.md);离可用客户端还差什么见
[`docs/BACKLOG.md`](docs/BACKLOG.md)(分层未完成清单);把参考图那种整张地图画面做出来的实施规格见
[`docs/SHINSOO-WORLD-RENDERING.md`](docs/SHINSOO-WORLD-RENDERING.md)(正式移植,Phase 2 bring-up 之后);
距参考图「目视等价」还差哪些保真化工作见 [`docs/PARITY-GAP.md`](docs/PARITY-GAP.md)。
--- ---
@@ -26,18 +35,26 @@
`test/golden/m1-bindpose.png`)。多骨架:`warrior_lord`(v6,74)、`assassin`(v6,95,17 表面)、 `test/golden/m1-bindpose.png`)。多骨架:`warrior_lord`(v6,74)、`assassin`(v6,95,17 表面)、
`shaman_lord`(**v7**,93) 均正确加载渲染。 `shaman_lord`(**v7**,93) 均正确加载渲染。
- [x] **M2** — 骨骼动画。`Metin2AnimPlayer` `_process``gr2::sample_pose`(跨文件) - [x] **M2** — 骨骼动画。`Metin2AnimPlayer` `_process``gr2::sample_pose`(跨文件)
逐骨 `Skeleton3D.set_bone_pose`(局部),Godot GPU 蒙皮。`general/wait` 头 / 护甲 **默认 CPU 线性混合蒙皮**(把 libgr2 的 `Σ w·invWorld·world` 直接作用到顶点,每帧重建 mesh)。
对齐正确(`test/golden/m2-wait.png`);`get_bone_global_pose` vs `conv(world)` 全骨 ≤2.6e-5 `get_bone_global_pose` vs `conv(world)` 全骨 ≤2.6e-5`selfcheck` 0 NaN`dance_1` 等所有动作
CPU-LBS 参考路径(`MTGODOT_CPUSKIN=1`)与 GPU 蒙皮一致;`selfcheck` 0 NaN 头/护甲/躯干正确,与 xrender-poc bgfx demo 一致
⚠️ `dance_1` 等表情动作头 / 颈塌陷 —— **libgr2 曲线解码 bugxrender-poc bgfx demo 同样复现** `MTGODOT_GPUSKIN=1` 走**自写蒙皮顶点着色器**`m2_material` `SRC_SKIN`:逐骨完整 4×3 矩阵
(见 `docs/MIDREVIEW.md` §4)。 存 RGBAF 纹理,绕过 `Skeleton3D` —— Godot 内置 GPU 蒙皮会正交化、丢 Granny 烘在 rig 骨上的
- [~] **M2.5** — 材质与观感保真。`ShaderMaterial`modulate tex×COLOR×light、 shearwarrior 7 根 / **sura 16 根**,见 `test/shear-bones-survey.md`)。人群场景用它。
opaque/alpha/alpha-test/add 四模式)+ 目录扫描贴图解析 + 方向光阴影 + procedural sky **`.msa`**`anim_path``.msa` → motion `.gr2` + `Accumulation` + `MotionEventData`
+ 引擎雾(`test/golden/m25-*.png`)。**门禁未闭**:多材质槽 → 贴图/混合的精确映射需 `get_events()` / `motion_event` 信号,带循环回绕)+ `LoopData` 元数据;播放器 `loop=false`
给 libgr2 加材质 API(见 `docs/GODOT-POC-PLAN.md` §M2.5 T2.5.6 差距清单)。 可播一次并发 `playback_finished`(片段循环次数执行仍在 backlog C4)。
**`.msm`**`gr2_path``.msm` → 自动加载 `BaseModelFileName`(含散包目录扫描)+
`get_hair_options()` 发型目录(发型 mesh 挂接留 Phase 2)。
- [x] **M2.5** — 材质与观感保真。`ShaderMaterial`modulate tex×COLOR×light、
opaque/alpha/alpha-test/add 四模式)+ 方向光阴影 + procedural sky + 引擎雾。
**贴图走 gr2 material 绑定**`build_parts()``tri_groups[].material_index` 把一个 gr2 mesh
拆成多个 Godot surface,各 surface 取 `Mesh::material_textures[matidx]`mesh-local)→
同目录大小写不敏感查 `.dds`;找不到才回退文件名启发式。shaman(1→2 面)/assassin(17→18 面)
各面贴图正确。MODULATE2X 等 stage op 仍只近似(差距清单见 `docs/GODOT-POC-PLAN.md` §M2.5 T2.5.6)。
- [~] **中期评审** — [`docs/MIDREVIEW.md`](docs/MIDREVIEW.md)。结论:桥接层工程量小、 - [~] **中期评审** — [`docs/MIDREVIEW.md`](docs/MIDREVIEW.md)。结论:桥接层工程量小、
风险基本出清,**建议进入 Phase 2**。收尾 3 项:`compare.py` 对拍、`dump_materials` 风险基本出清,**建议进入 Phase 2**。`compare.py` 已有进程/截图/结构硬门禁,像素差仍为
Granny 对拍闭合 M2.5 门禁、前台/真机性能重测 advisory;收尾重点是前台/真机性能重测与桥接层全语料 draw 冒烟(默认已是 CPU LBS
50 角色压力采样:`test/godot-macos-stress.json`(后台窗口 30fps 节流,仅构建成本 50 角色压力采样:`test/godot-macos-stress.json`(后台窗口 30fps 节流,仅构建成本
~165ms/角色 和 100 角色破顶到 52ms 是可信信号)。 ~165ms/角色 和 100 角色破顶到 52ms 是可信信号)。
@@ -49,10 +66,10 @@
| 组件 | 版本 / 位置 | 说明 | | 组件 | 版本 / 位置 | 说明 |
|---|---|---| |---|---|---|
| Godot | **4.7.1**`/opt/homebrew/bin/godot`Homebrew cask | 编辑器 + headless | | Godot | **4.7.1**`/opt/homebrew/bin/godot`Homebrew cask | 编辑器 + headless |
| godot-cpp | submodule `extension/godot-cpp` @ `master`pin `101ae38` | master 默认 targets Godot 4.7 API`GODOTCPP_DEFAULT_API_VERSION=4.7`);无 `4.6/4.7` 分支,只有 bundled `extension_api-4-7.json` | | godot-cpp | submodule `extension/godot-cpp` @ `101ae38034304346a46ea9ea84ae156d3e860496` | 精确 gitlink 锁定(`.gitmodules` 不跟踪移动分支);bundled `extension_api-4-7.json` |
| Xcode | 26.4.1 | macOS 构建 | | Xcode | 26.4.1 | macOS 构建 |
| CMake | 已装;**不需要 SCons** | godot-cpp 走 CMake 路径 | | CMake | 已装;**不需要 SCons** | godot-cpp 走 CMake 路径 |
| libgr2 | `../xrender-poc/libgr2`sibling-dir 引用) | 见下「libgr2 依赖」 | | libgr2 / formats / oracle / tools | vendored 进本仓库 | 见下「vendored 库」 |
**导出模板**M0' T0.4 / 真正 export 才需要):编辑器内 `Editor → Manage Export Templates → Download`,或 `godot --headless --install-export-templates`。~600MB,版本须与编辑器一致(4.7.1)。 **导出模板**M0' T0.4 / 真正 export 才需要):编辑器内 `Editor → Manage Export Templates → Download`,或 `godot --headless --install-export-templates`。~600MB,版本须与编辑器一致(4.7.1)。
@@ -66,19 +83,38 @@ git submodule update --init --recursive # 拉 godot-cpp(首次)
./build.sh Release # template_release ./build.sh Release # template_release
``` ```
首次会编译 godot-cpp(几分钟)。 首次会编译 godot-cpp(几分钟)。扩展构建自动带上 vendored `libgr2` + `formats`
只想构建 `libgr2` / `formats` / 校验工具(不碰 godot-cpp):
```bash
cmake -B build-tools -DMTGODOT_BUILD_EXTENSION=OFF -DMTGODOT_BUILD_TOOLS=ON
cmake --build build-tools -j8
./build-tools/tools/gr2fuzz "$PWD/assets" # 全量解析冒烟
./build-tools/tools/gr2dump <file.gr2>
```
## 运行 ## 运行
```bash ```bash
# 完整客户端:登录 → 选人 → 进游戏(用仓库内 assets/)
./run-client.command # 或双击
# = MT_ASSETS=$PWD/assets godot --path project res://client_main.tscn
# 打包 .app(需先装 Godot 4.7.1 macOS 导出模板)
./build-macos-client.sh [debug|release] # -> build/export/mtgodot-poc.app
# 编辑器打开 # 编辑器打开
godot -e --path project godot -e --path project
# 模型查看器 harness(旧 main_scene
godot --path project res://main.tscn
# headless 冒烟测试(验证 GDExtension 加载 + 类注册) # headless 冒烟测试(验证 GDExtension 加载 + 类注册)
godot --headless --path project --quit-after 3 godot --headless --path project --quit-after 3
# 跑一个真实 gr2 过 libgr2(可选) # 跑一个真实 gr2 过 libgr2(可选)
MTGODOT_PROBE_GR2="$PWD/../m2dev-client-main/assets/PC/ymir work/pc/warrior/warrior_cheongrin.gr2" \ MTGODOT_PROBE_GR2="$PWD/assets/PC/ymir work/pc/warrior/warrior_cheongrin.gr2" \
godot --headless --path project --quit-after 3 godot --headless --path project --quit-after 3
``` ```
@@ -88,22 +124,23 @@ MTGODOT_PROBE_GR2="$PWD/../m2dev-client-main/assets/PC/ymir work/pc/warrior/warr
--- ---
## libgr2 依赖 ## vendored 库(并入自 xrender-poc2026-08-29
M0' 直接用 **sibling-directory 引用**`extension/CMakeLists.txt` | 目录 | 内容 | 谁用 |
`XRENDER_POC_DIR` 默认 `../../xrender-poc``add_subdirectory``libgr2` |---|---|---|
两个 repo 都在 `.../mt/` 下时开箱即用。 | `libgr2/` | gr2 v6/v7 只读读取器(header/section/Oodle1/类型树/骨架/网格/曲线/材质)。零第三方依赖。9166 语料 fuzz 0 崩溃;bind pose + 蒙皮对拍 Granny 2.9.12 ≤6.5e-5。含 `dump_materials()`。 | `extension``xrender::libgr2`)、`tools` |
| `formats/` | `.msa`(动作)/ `.msm`(模型 + 发型)/ textscript 解析。 | M2 T2.2/T2.3 起(retarget + motion event |
| `oracle/` | Wine + MinGW 交叉编译的 Granny 真值工具(`oracle.c` + 脚本 + `RUNBOOK.md`)。`oracle.exe` / `granny2_x64.dll` 不入库(体积 + RAD IP),`build-wine.sh` 本地重建。 | M2 数值门禁、M2.5 材质对拍 |
| `tools/` | `gr2dump`(转储)/ `gr2fuzz`(全量解析冒烟)/ `oracle_diff`libgr2 vs oracle 逐字段对拍)/ `anim_probe`(每骨 world 3×3 的 det/shear/scale 探针,查蒙皮走样)。 | libgr2 回归 |
| `docs/reference/` | xrender-poc 的格式逆向笔记(M0M4 施工文档 + PLAN)。见 `docs/reference/README.md`。 | 查格式细节 |
| `test/bgfx-reference/` | bgfx demo 的参考截图 + `noise_floor.json` / `m2-numeric.json` / `assets.list` 基线。 | 与 Godot 输出交叉核对 |
换机 / CI 时二选一: CMake:顶层 `add_subdirectory(libgr2)` + `add_subdirectory(formats)`(跨平台,不碰 godot-cpp);
- `git submodule add <xrender-poc url> third_party/xrender-poc` 后传 `-DXRENDER_POC_DIR=third_party/xrender-poc` `extension` `target_link_libraries(... xrender::libgr2)``tools`
- vendor 一份 `libgr2/` 进本仓库 `-DMTGODOT_BUILD_TOOLS=ON` 开启。
> ⚠️ 本仓库依赖 `../xrender-poc/libgr2` 里新增的 `dump_materials()` > `libgr2/README.md` 里的 **oodle1.c clean-room 提醒**依然适用:`src/oodle1.c` 是泄露 SDK 端口,
> `src/gr2_material.cpp` + `include/gr2/gr2.h` + `CMakeLists.txt` 三处,纯附加) > 仅限内部研究 / 非发布 / 非商用;对外发布前必须 clean-room 重写
> 该改动目前只在 xrender-poc 工作树里,**未提交**(那个仓库无提交历史,保持原样)。
> 需要时由 xrender-poc 的维护者按其流程纳入。
`formats/`msa/msm)到 **M2** 才需要,届时同样方式接入。
--- ---
@@ -111,27 +148,42 @@ M0' 直接用 **sibling-directory 引用**`extension/CMakeLists.txt` 里
``` ```
mtgodot-poc/ mtgodot-poc/
CMakeLists.txt 顶层macOS only 守卫) CMakeLists.txt 顶层add_subdirectory(libgr2/formats/[tools]/[extension])
build.sh 便捷构建 build.sh 便捷构建(扩展)
libgr2/ ← vendoredgr2 读取器(xrender::libgr2
formats/ ← vendoredmsa/msm/textscript
oracle/ ← vendoredGranny 真值工具(exe/dll 不入库)
tools/ ← vendoredgr2dump / gr2fuzz / oracle_diff
extension/ extension/
CMakeLists.txt mtgodot SHARED + godot-cpp + libgr2 CMakeLists.txt mtgodot SHARED + godot-cppconsume xrender::libgr2
godot-cpp/ submodule @ master godot-cpp/ submodule @ 101ae38034304346a46ea9ea84ae156d3e860496
src/ src/
register_types.{h,cpp} GDExtension 入口,注册 Metin2Model register_types.{h,cpp} GDExtension 入口
metin2_model.{h,cpp} M0' 占位节点(证明类注册 + libgr2 链接) metin2_model.{h,cpp} gr2 → Skeleton3D + ArrayMesh + Skin + 材质
metin2_anim.{h,cpp} 每帧 sample_pose → Skeleton3Dselfcheck
gr2_bridge.{h,cpp} libgr2 POD → Godot 类型;basis + 单位换算
m2_material.{h,cpp} Metin2 风格 ShaderMaterialmix / add
dxt.{h,cpp} DDS DXT1/3/5 软解
assets/ Metin2 资产(.gr2/.dds/locale/OutdoorA1/…,gitignored;换位置设 MT_ASSETS
bgm/ 背景音乐(assets/ 同级,gitignored
project/ Godot 4.7 工程 project/ Godot 4.7 工程
project.godot client_main.tscn/.gd 完整客户端入口(main_scene)→ AppFlow 登录串场
main.tscn 根 Node3D + main.gd app_flow.gd LOGIN → SELECT → GAME 状态机
main.gd M0' harness:建 env/light/cam/cube + 探测扩展 main.tscn / main.gd 旧模型查看器 harness
assets/.gdignore 让 Godot 不 import .gr2/.dds asset_root.gd AssetRoot:资源目录解析(MT_ASSETS / res://../assets / .app 内外)
bin/mtgodot.gdextension 扩展描述符(dylib 构建产物落这里)
export_presets.cfg macOS preset
docs/ docs/
GODOT-POC-PLAN.md 开发计划(M0'M4 + 风险 + 验收) GODOT-POC-PLAN.md 开发计划(M0'M4 + 风险 + 验收)
steps/ (按需) MIDREVIEW.md Phase 1 中期评审
test/golden/ 对拍截图(M1 起) BACKLOG.md 离可用客户端的分层未完成清单
reference/ ← xrender-poc 的格式逆向笔记(archived
test/
golden/ Godot 对拍截图
bgfx-reference/ bgfx demo 参考截图 + 数值基线
*.json noise_floor / m2-numeric / stress
``` ```
## 授权 ## 授权
`libgr2` 全自研,不含 Granny SDK 代码。本仓库内部研究用途,不对外公开。 `libgr2` 全自研,不含 Granny SDK 代码 —— 例外:`libgr2/src/oodle1.c` 是泄露 SDK 的解码路径端口
(见 `libgr2/README.md`,仅限内部研究 / 非发布,对外发布前必须 clean-room 重写)。
本仓库内部研究用途,不对外公开。
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env bash
# Cross-compile the mtgodot GDExtension for Android (OnePlus 13 / arm64 / Vulkan).
# Produces project/bin/libmtgodot.android.template_{debug,release}.arm64.so.
#
# On-device run + APK export is the rest of BACKLOG F2; see docs/PLATFORMS.md.
#
# Needs the Android NDK. Point at it with ANDROID_NDK_ROOT (or install via
# Android Studio → SDK Manager → NDK, default ~/Library/Android/sdk/ndk/<ver>).
set -euo pipefail
cd "$(dirname "$0")"
CONFIG="${1:-Debug}" # Debug | Release
ABI="${ANDROID_ABI:-arm64-v8a}"
API="${ANDROID_PLATFORM:-24}"
BUILD_DIR="build-android"
JOBS="${JOBS:-$(sysctl -n hw.ncpu)}"
# --- locate the NDK ---
NDK="${ANDROID_NDK_ROOT:-${ANDROID_NDK_HOME:-}}"
if [ -z "$NDK" ]; then
SDK="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-$HOME/Library/Android/sdk}}"
if [ -d "$SDK/ndk" ]; then
NDK="$SDK/ndk/$(ls -1 "$SDK/ndk" | sort -V | tail -1)"
fi
fi
if [ -z "$NDK" ] || [ ! -f "$NDK/build/cmake/android.toolchain.cmake" ]; then
echo "Android NDK not found. Set ANDROID_NDK_ROOT to an NDK with" >&2
echo " build/cmake/android.toolchain.cmake (install via Android Studio SDK Manager)." >&2
exit 1
fi
echo "NDK: $NDK"
if [ ! -f extension/godot-cpp/cmake/android.cmake ]; then
echo "godot-cpp submodule missing — git submodule update --init --recursive" >&2
exit 1
fi
cmake -S . -B "$BUILD_DIR" -G "Unix Makefiles" \
-DCMAKE_TOOLCHAIN_FILE="$NDK/build/cmake/android.toolchain.cmake" \
-DANDROID_ABI="$ABI" \
-DANDROID_PLATFORM="android-$API" \
-DCMAKE_BUILD_TYPE="$CONFIG" \
-DMTGODOT_BUILD_TOOLS=OFF \
-DBUILD_TESTING=OFF
cmake --build "$BUILD_DIR" --target mtgodot -j "$JOBS"
echo
echo "Android ($CONFIG / $ABI) library:"
ls -la project/bin/libmtgodot.android.*.so
Executable
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env bash
# Cross-compile the mtgodot GDExtension for iOS (iPhone 16 / arm64 device) and
# stage every static archive the app needs to link into project/bin/ios/.
#
# This gets you a compiled .a — it does NOT do the Xcode project / signing /
# on-device run. That's the rest of BACKLOG F1; see docs/PLATFORMS.md.
#
# Needs: full Xcode + iPhoneOS SDK. Submodules (recursive) must be present.
set -euo pipefail
cd "$(dirname "$0")"
CONFIG="${1:-Debug}" # Debug | Release
DEPLOY="${IOS_DEPLOYMENT_TARGET:-15.0}"
BUILD_DIR="build-ios"
JOBS="${JOBS:-$(sysctl -n hw.ncpu)}"
TOOLCHAIN="extension/godot-cpp/cmake/ios.toolchain.cmake"
case "$CONFIG" in
Release) TPL="template_release" ;;
*) TPL="template_debug" ;;
esac
if [ ! -f "$TOOLCHAIN" ]; then
echo "godot-cpp submodule missing — git submodule update --init --recursive" >&2
exit 1
fi
cmake -S . -B "$BUILD_DIR" -G "Unix Makefiles" \
-DCMAKE_TOOLCHAIN_FILE="$TOOLCHAIN" \
-DPLATFORM=OS64 \
-DDEPLOYMENT_TARGET="$DEPLOY" \
-DCMAKE_BUILD_TYPE="$CONFIG" \
-DMTGODOT_BUILD_TOOLS=OFF \
-DBUILD_TESTING=OFF
cmake --build "$BUILD_DIR" --target mtgodot -j "$JOBS"
# --- stage archives for the .gdextension [dependencies] block ---
OUT="project/bin/ios"
mkdir -p "$OUT"
cp -f "$BUILD_DIR/bin/libgodot-cpp.ios.${TPL}.arm64.a" "$OUT/"
cp -f "$BUILD_DIR/libgr2/liblibgr2.a" "$OUT/"
cp -f "$BUILD_DIR/formats/libxr_formats.a" "$OUT/"
cp -f "$BUILD_DIR/extension/libmtnet.a" "$OUT/"
cp -f "$(find "$BUILD_DIR" -name libsodium.a | head -1)" "$OUT/"
echo
echo "iOS ($CONFIG) archives:"
ls -la "project/bin/libmtgodot.ios.${TPL}.a" "$OUT"/*.a
lipo -info "project/bin/libmtgodot.ios.${TPL}.a"
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env bash
# 打包 macOS 完整客户端(登录 → 选人 → 进游戏)。
#
# ./build-macos-client.sh [debug|release]
#
# 产物:build/export/mtgodot-poc.app + 旁边一个 assets 符号链接(不进包,2.1G)。
# 依赖:Godot 4.7.1 的 macOS 导出模板(editor 里 “管理导出模板” 装,或放
# ~/Library/Application Support/Godot/export_templates/4.7.1.stable/macos.zip)。
set -euo pipefail
cd "$(dirname "$0")"
REPO="$(pwd)"
CFG="${1:-debug}"
OUT="$REPO/build/export"
APP="$OUT/mtgodot-poc.app"
GODOT="${GODOT:-godot}"
echo "== 1. GDExtension .dylib =="
cmake --build build --target mtgodot -j8
DYLIB="project/bin/libmtgodot.macos.template_${CFG}.dylib"
if [ ! -f "$DYLIB" ]; then
# CMake 目前只出 debug 名;release 就复用它
cp -f project/bin/libmtgodot.macos.template_debug.dylib "$DYLIB"
fi
echo "== 2. 导出 =="
mkdir -p "$OUT"
if [ "$CFG" = release ]; then
"$GODOT" --headless --path project --export-release macOS "$APP"
else
"$GODOT" --headless --path project --export-debug macOS "$APP"
fi
echo "== 3. 资源(不打进包)=="
# .app 同级放一个 assets 链接;AssetRoot 会在 <.app 同级>/assets 找到它。
ln -sfn "$REPO/assets" "$OUT/assets"
ln -sfn "$REPO/bgm" "$OUT/bgm"
echo "== 4. ad-hoc 签名(Apple Silicon 直跑)=="
codesign --force --deep --sign - "$APP" 2>/dev/null || true
xattr -dr com.apple.quarantine "$APP" 2>/dev/null || true
echo
echo "OK -> $APP"
echo "跑: open '$APP' (用 $OUT/assets"
echo "或: MT_ASSETS='$REPO/assets' '$APP/Contents/MacOS/mtgodot-poc' (看控制台日志)"
+7 -3
View File
@@ -6,9 +6,13 @@ cd "$(dirname "$0")"
CONFIG="${1:-Debug}" # Debug | Release CONFIG="${1:-Debug}" # Debug | Release
JOBS="${JOBS:-$(sysctl -n hw.ncpu)}" JOBS="${JOBS:-$(sysctl -n hw.ncpu)}"
if [ ! -f extension/godot-cpp/CMakeLists.txt ]; then # godot-cpp + the vendored deps (zstd, libsodium-cmake and its nested libsodium)
echo "godot-cpp submodule missing — run: git submodule update --init --recursive" >&2 # are submodules — recurse. miniLZO is vendored in-tree, nothing to fetch.
exit 1 if [ ! -f extension/godot-cpp/CMakeLists.txt ] \
|| [ ! -f extension/third_party/zstd/build/cmake/CMakeLists.txt ] \
|| [ ! -f extension/third_party/libsodium-cmake/libsodium/src/libsodium/include/sodium.h ]; then
echo "submodules missing — fetching (git submodule update --init --recursive)…" >&2
git submodule update --init --recursive
fi fi
cmake -S . -B build -DCMAKE_BUILD_TYPE="$CONFIG" cmake -S . -B build -DCMAKE_BUILD_TYPE="$CONFIG"
+205
View File
@@ -0,0 +1,205 @@
# mtgodot-poc — 未完成工作清单
> 更新:2026-08-29 · 配套 [`GODOT-POC-PLAN.md`](./GODOT-POC-PLAN.md)(计划)、[`MIDREVIEW.md`](./MIDREVIEW.md)(中期评审)、
> [`SHINSOO-WORLD-RENDERING.md`](./SHINSOO-WORLD-RENDERING.md)(世界渲染实施)、[`PARITY-GAP.md`](./PARITY-GAP.md)(距参考图的保真化差距)。
>
> POC 的核心问题——**Godot + 自研 loader 能否正确读 + 渲 + 播 Metin2 `.gr2` 骨骼模型**——
> 在 macOS 上已答「能」:数值对拍 Granny ≤6.5e-5`warrior` `dance_1` 的 CPU 与 GPU(自写着色器)
> 蒙皮两条路径已**抽帧核对**t=7 / t=19)一致;`sura`/`assassin`/`shaman`(含 v7)加载渲染正常(未逐帧对拍)。
> 本文件是「离一个可用客户端还差什么」的分层清单,供正式移植排期。
>
> **状态**:✅ 已做 · 🟡 部分 · ⬜ 未做 · ⏭ 推迟到正式移植(不影响 Phase 1 中期评审 / Phase 2 bring-up · ❌ 本 POC 明确不做
>
> **规模**(粗估,单人 / 对本代码库熟悉后,置信度低 —— 排期时须自行细化):
> S ≈ 1–2 天 · M ≈ 1–2 周 · L ≈ 3 周+ · XL ≈ 独立子项目
>
> ID 稳定,可在 commit / issue 里引用(`A1` `B3` …)。B 段取代
> [`GODOT-POC-PLAN.md`](./GODOT-POC-PLAN.md) §M2.5 T2.5.6 的差距清单(那处不再单独维护)。
---
## A. Phase 1 收尾(SM
| ID | 项 | 状态 | 规模 | 说明 / 依赖 |
|---|---|---|---|---|
| A1 | 发型 mesh 实际挂接 | 🟡 | M | ✅ CPU 路径:`Metin2Model::set_hair_gr2()` + `_load_hair()` —— 加载发型 gr2,骨骼**按名重映射**到 base skeletonwarrior 74/74 匹配),hair mesh 并入 `cpu_skin` 每帧蒙皮,`alpha_scissor` 镂空材质。✅ GPU 蒙皮路径的 hair(`_build_gpu_mesh` 并入 + `ARRAY_BONES` remap,无需 shader 改)。✅ 武器挂点(C5);`.msm target_skin` 换色。 |
| A2 | anim player 与 model 共享逐帧矩阵 | ✅ | S | `Metin2Model::current_skin_matrices()` 缓存完整 affine deformer 矩阵:reload 初始化为 bind poseCPU/GPU 路径逐帧刷新;发型 / 武器 C++ 桥接可直接复用。 |
| A3 | `_frame_model` 相机框选 | ✅ | S | 已改为按所有 surface 顶点的 1%–99% 分位盒取景并加 8% 边距;assassin headless 冒烟得到稳定 `trimmed-mesh` 框选,孤立几何和远端挂点不再拖偏相机。 |
| A4 | 静态(无动画)展示路径统一 | 🟡 | S | 无 anim player 时走 `Skeleton3D` bind pose(与动画时的 `cpu_skin`/`gpu_skin` 不同代码路径)。实测 M1 bind pose 正确(`skin_matrix ≈ I`,见 `test/shear-bones-survey.md`),非 bug;为一致性 + 少一条代码路径,宜统一走蒙皮路径的 bind pose。 |
| A5 | CPU 路径每帧全量重建 mesh | 🟡 | S–M | `cpu_mesh->clear_surfaces()+add_surface_from_arrays` 每帧全量(**疑似** 30 角色 24fps 的主因 —— 未 profile 确认,见 F4)。改成原地 `Mesh::surface_update_vertex_region`,或人群直接切 `MTGODOT_GPUSKIN=1`。 |
| A6 | `ShaderMaterial` 静态 Shader 退出泄漏 | ✅ | S | Shader cache 由扩展在 scene-level terminator 显式 `unref()`CPU/GPU headless 退出不再报告该静态引用泄漏。 |
| A7 | GPU 蒙皮跑 stress harness | 🟡 | S | 已确认 `_run_stress` 会让每个实例进入 bone-texture GPU LBS,且 headless 2 角色冒烟成功;报告文案已纠正。headless dummy renderer 的帧数无性能意义,仍缺前台 Metal 人群基线。 |
| A8 | loader 健壮性 | 🟡 | S | 解压失败、section 越界与 `build_fileinfo` 失败现会严格传播;新增 4 类损坏输入 CTest,全量真实语料仍为 9166/9166。仍缺 0 骨 / 索引越界 / NaN 的桥接层用例及 H1 draw 冒烟。 |
| A9 | `reload()` 遗留蒙皮状态 | ✅ | S | 重载/切模型前现在清空 `cpu_mesh`、GPU 标志、bone texture、parts/material cache 与 `.msm` 派生状态,避免复用旧模型资源或 CPU/GPU 双重蒙皮。 |
| A10 | 动画 mesh 裁剪盒 | ✅ | S | CPU LBS 每帧按有限的变形后顶点刷新 `custom_aabb`;GPU 路径继续使用保守扩展盒。 |
---
## B. 材质 / 观感保真(M2.5M;取代 GODOT-POC-PLAN §M2.5 T2.5.6
**现状**:贴图 = gr2 material 绑定(`Mesh::material_textures[matidx]`mesh-local`use_gr2_materials=true` 默认)
→ 找不到时 `_resolve_texture` 文件名启发式(face/hair/stem 关键字 + 目录扫描)。blend / cull / alpha
全靠 `guess_blend()` 按**表面名关键字**猜。着色器 `SRC_MIX` / `SRC_SKIN`skinned/
`SRC_ADD` / `SRC_ADD_SKIN`additive;静态与 GPU 蒙皮均覆盖)。
**前置 B1**libgr2 已有 `MaterialInfo{name, diffuse_texture, all_textures, map_count, alpha_blend, two_sided}` +
`Mesh::material_textures`,其中 blend/two-sided 仍是名字、贴图数与 ExtendedData 的不完整推断;任意 D3D stage 状态不在 GR2 中。
| ID | 项 | 状态 | 规模 | 说明 |
|---|---|---|---|---|
| B1 | libgr2 暴露 blend / cull / alpha 数据 | 🟡 | M | Metin2 gr2 **不带 D3DRS** —— 客户端靠 Name(`Blend*` + MapCount>1) + ExtendedData("Two-sided")。libgr2 `MaterialInfo` 已加 `map_count` / `alpha_blend` / `two_sided`(名字 + map 推断,`static_object.cpp` 已用来切 `TRANSPARENCY_ALPHA` / `CULL_DISABLED`)。gr2fuzz 9166/9166 不回归。⬜ 精确解析 ExtendedData 变体,并由 B2 端口原客户端实际使用的固定功能材质分支。 |
| B2 | 原客户端固定功能材质状态兼容 | ⬜ | M | 现在只近似单 stage `MODULATE(TEXTURE, DIFFUSE)`。先盘点 `GameLib/ActorInstanceRender.cpp``EterGrnLib/ModelInstanceRender.cpp``EterGrnLib/Material.cpp` 在目标资产上实际触发的 blend/alpha-test/sphere-map/多贴图分支,再扩 `SRC_MIX`GR2 不承载任意 D3D stage 状态,不按未经证实的 op 清单猜实现。 |
| B3 | alpha-test(镂空) | 🟡 | S | hair 表面名 → `AlphaTest`B4 里 `decide_blend` 仍保留这条,GR2 材质 flag 不区分镂空 vs 不透明)。⬜ 改成贴图 alpha 分布判定;阈值硬编码 0.5,应取材质 `D3DCMP_*` ref。 |
| B4 | blend 模式取自材质 | ✅ 首版 | S | `metin2_model` `decide_blend()`**优先 `gr2::MaterialInfo.alpha_blend`**`dump_materials` 端口 EterGrnLib Material.cpp:233 = Name `Blend*` + MapCount>1),仅 hair 镂空 / effect 加法这两类 GR2 没编码的走表面名兜底。`decide_two_sided()``MaterialInfo.two_sided` ‖ hair/cloak/cape/skirt/leaf 名。`match_material()` 按 diffuse_texture/name basename 回查。两处(body + weapon)都改。m2_material 加 `cull_disabled` shader 变体(`shader_for(add,skin,two_sided)`)。⬜ 精确 ExtendedData 变体、alpha-test ref 取材质 `D3DCMP_*`。 |
| B5 | 每材质多张贴图(spec / mask / …) | ⬜ | M | 现在只解析 diffuse。`Mesh::material_textures` + libgr2 材质 API 扩成 map。 |
| B6 | 贴图采样设置(filter / wrap / mip / 各向异性) | ⬜ | S | 现在 shader 里写死 `filter_linear_mipmap, repeat_enable`。 |
| B7 | 顶点色 | ✅ 排查=不适用 | — | 全资产 `.gr2`PC/monster2/effect)无 `DiffuseColor`/`Color0` 顶点成员(grep assets = 0)。Metin2 逐顶点色 = 地形雾(`MapOutdoorRenderSTP` 运行时动态 VB,非资产,Godot `Environment` 雾已覆盖)+ `.mse` `colorfactor`/粒子色(属 E7/§1.2)。libgr2 / `build_mesh` 无需动。详见 PARITY §2.6。 |
| B8 | 切线帧(若确有法线贴图资产) | ⬜ | M | PNT332 无切线。xrender 时期结论:Metin2 资产**基本不用**法线贴图 —— 先排查有没有,再决定是否做(`SurfaceTool::generate_tangents()` 或从 UV 算)。 |
| B9 | 镜面 / 自发光 | 🟡 | M | Sphere-map 高光管线已端口(`SRC_MIX`/`SRC_SKIN``spec_map`/`spec_power`/`spec_enable`,复现 `Material.cpp:305 __ApplySpecularRenderState`:不透明面、相机空间 `reflect().xy``spheremap.jpg``EMISSION += sphere*tex.a*power`)。`Metin2Model.specular_power` 属性,默认 0 休眠 → 需装备层用 `item_proto bSpecular/100` 逐部位驱动。`SRC_ADD` / `SRC_ADD_SKIN` 已覆盖 additive 发光的一种,且 additive 动画面不再漏蒙皮。 |
| B10 | 背面剔除 / 双面 | 🟡 | S | 世界静态对象(W3/B1)已按 `two_sided` + 贴图名切 `CULL_DISABLED`。角色 `m2_material` shader 仍写死 `cull_back``MaterialDesc::two_sided` 死字段)。需给角色也按材质切。 |
| B11 | winding 逐模型验 | ⬜ | S | `flip_winding` 默认 false(4 个骨架验过,未全量)。 |
---
## C. 动画运行时(SL
| ID | 项 | 状态 | 规模 | 说明 |
|---|---|---|---|---|
| C1 | root motion`Accumulation` | 🟡 | S | `.msa` 已解析、`get_accumulation()` 暴露;未作用到节点 transform。 |
| C2 | 动画混合 / crossfade | 🟡 | M | ✅ `Metin2AnimPlayer.blend_time`(默认 0.15s):切 `anim_path` 时旧 clip 冻结,逐骨对旧/新 `world_pose` slerp+lerp`skin = invWorld · world_blended` 重建;`mul4x3` 复用 libgr2 语义。权重曲线改为 **`2w²−w³`ease-in),对齐客户端 `GrannySetControlEaseInCurve(t0,t1,0,0,1,1)` Hermite**(原用 smoothstep)。⬜ shear 骨过渡期精确保持、受控帧核对时长、非循环 clip 结束回退(现 `playing=false` 定格,`apply_pose` 已 clamp,够用)。 |
| C3 | additive 动画 / 部分身体 mask | ⬜ | M | 上半身动作叠在移动上等。 |
| C4 | `.msa` loop / 一次性动作 | 🟡 | S | 已增加整段 `loop` 属性:false 时夹到末帧、停止并发 `playback_finished`;实际 `.msa LoopData`count/cancel/start/end)已解析并由 `get_loop_data()` 暴露,真实资产冒烟通过。剩余:按原客户端规则执行片段循环次数/取消窗口;整段 loop/once 仍需由动作注册层指定,不能从 `LoopData` 猜。 |
| C5 | 挂点系统 | 🟡 | M | ✅ 武器挂手骨:`Metin2Model::set_weapon_gr2()` / `set_weapon_bone()`(默认 `equip_right_hand`),子 `MeshInstance3D` 每帧取 `gr2::sample_pose``world_pose[bone]``Metin2AnimPlayer::apply_pose` 驱动;对齐客户端 `GetBoneMatrixPointer`)。⬜ 特效挂骨、左手/双持/盾、`.mse` trail。 |
| C6 | motion event 类型语义 | 🟡 首版 | M | `game_scene._on_local_motion_event` 分派(对齐 `RaceMotionData EMotionEventType`):有 `sound``_audio.play_at`;有 `effect``fx.spawn_at(basename)``EffectPosition`type 2 `SCREEN_WAVING``cam.shake`type 3 `SCREEN_FLASHING` → 屏闪 ColorRecttype 6/9 `FLY/WARP` → 钩子(skill_fx 已单独处理飞行物)。`eterngrn_polish_test.gd` 覆盖。⬜ `.msa` type 数字与运行时枚举的历史偏差核对、武器拖尾(C 未列,见 WeaponTrace)、投掷点精确。 |
| C7 | 播放速率(数据驱动) | ⬜ | S | `time_scale` 有,未按动作类型 / 装备驱动。 |
| C8 | IK / look-at / 踩地校正 | ⬜ | L | 无。 |
| C9 | LOD 选择 | 🟡 | M | ✅ 距离切 mesh`lod_distances` 默认 `[18,42,90]`m,重建 `cpu_mesh`+材质+蒙皮)。**+ 淡入淡出**:切档时把旧 mesh 快照进 `LodGhost` MeshInstance3D(冻结),shader `lod_fade` uniformmix/skin)在 ~0.18s 内旧 1→0 / 新 0→1`LODController::BlendRenderWithOneTexture`);距离阈值加 15% 迟滞防抖。⬜ GPU skin 路径的 LOD、受控帧核对。 |
---
## D. 模型组合(SL
| ID | 项 | 状态 | 规模 | 说明 |
|---|---|---|---|---|
| D1 | 多部件装配(身体 + 武器 + 发型 + 时装 + 翅膀,共享骨架) | ⬜ | L | `.msm` + 物品系统。核心:多 `MeshInstance3D` 绑同一骨架 + 骨名重映射;部位遮挡 / 隐藏规则。 |
| D2 | 武器模型(独立 `.gr2` 挂手骨) | 🟡 | M | ✅ 右手单持跑通(见 C5rigid mesh 分支 `build_mesh` 已验,warrior + `00040.gr2` idle/run 跟手,贴图按材质绑定名解析)。⬜ 左手/双持/盾、`.msm`/物品系统给路径、`_lod` 变体。 |
| D3 | 贴图换色(`SourceSkin``TargetSkin` | 🟡 | S | ✅ 发型:`Metin2Model.hair_skin`TargetSkin dds 覆盖发型 albedo,对齐 `SetMaterialImagePointer`);`get_hair_options()` 返回绝对 `source_skin`/`target_skin``formats.msm_hair` CTest。⬜ 护甲/时装部位的 skin 表。 |
| D4 | 装备 / 时装换装系统 | ⬜ | L | 材质槽、部位互斥、隐藏规则。D1 的上层。 |
---
## E. 渲染功能(SXL
> `E7``E10`(特效 / 地形 / 水 / 植被)+ 地图加载 / 环境 / 第三人称场景的完整实施规格见
> [`SHINSOO-WORLD-RENDERING.md`](./SHINSOO-WORLD-RENDERING.md)W0W8 / R1 / R2,≈3.55.5 月起,
> Phase 2 三设备 bring-up 之后启动;自用,无正式门禁,W0 可提前并行)。对应的地图 / 环境 / 资产解析
> 工作项见下方 `E11``E13`,以及 `G7` / `H7` / `I5`。
| ID | 项 | 状态 | 规模 | 说明 |
|---|---|---|---|---|
| E1 | 阴影调优 | 🟡 | SM | `DirectionalLight3D` shadow 已开,出图看着正常;range / cascade / bias 未按 `unit_scale=0.01` 系统性调,需验漏影 / 摩尔纹。✅ 分层(PARITY §3.3):树 + `shadowflag≠1` 静态物体不投实时阴影(靠烘焙 `shadowmap.dds`),`shadowflag=1` + 角色投实时;`tree_shadows`/`static_shadows` 开关。 |
| E2 | 环境 | 🟡 | M | 角色演示:procedural sky + sky ambient。**世界(W5**`extension/src/environment_builder.cpp``.msenv``Sun`(方向光)+ `WorldEnv`Gradient 三段天空 / Material.Ambient / 深度雾 foglevel / Filmic+adjustment)。⬜ Character 光、云层、lens flare、昼夜。SHINSOO §9-W5。 |
| E3 | 后处理 | 🟡 | M | tonemapFilmic+ adjustment 调色 + glowintensity 0.35+ SSAOradius 1.2+ MSAA 4× 已开。⬜ 按受控参考帧标定曝光/对比/饱和/glow/SSAOTAA/FXAA 按平台取舍。 |
| E4 | 雾 | 🟡 | S | 角色演示 `MTGODOT_FOG=1`。世界(W5):`environment_builder``.msenv foglevel` 设深度雾 begin/end + 雾色。⬜ 与 `.wtr` 水雾 / 高度雾绑定。 |
| E5 | 透明排序 | 🟡 | M | `render_priority` 启发式。自重叠 alpha(头发 / 飘带)可能排错;需按深度排序或 OIT。 |
| E6 | Decal / 投影器 | ⬜ | S | 血迹 / 阴影贴花。 |
| E7 | 特效 / 粒子系统(`.mse`) | ⏭ | LXL | 真实资产是 ASCII Group/List 文本,原客户端 `EffectLib/EffectData.cpp``ParticleSystemData.cpp``EffectMesh.cpp``SimpleLightData.cpp` 已有 parser/语义真值。W7 首版尚未端口。任务是把 Particle/Mesh/Light/时间事件和挂点映射到 Godot,不是逆向私有二进制;先做最终机位必要子集,不阻塞 Phase 2 bring-up。SHINSOO §9-W7。 |
| E8 | 地形 | 🟡 | XL | ✅ 几何(W1):`formats/terrain_mesh.cpp` + `Metin2World`A1 20/20 `ArrayMesh` + UV`sample_height` 对拍 areadata z)。✅ splat 数据(W2):`formats/splat.cpp` 端口 `RAW_GenerateSplat``terrain_splat.cpp` 用 2 张 RGBA 权重图 + `Texture2DArray` 出图。⚠️ 当前 `min(8, splat.layers.size())`,而 A1 单区块最多 13 个非零层,会静默丢层;UV 也仍是错误的 `128/scale`,应改为区块归一化 U=`8*UScale`、V=`-8*VScale`。✅ 流式首版:逐区块节点 + 3×3 装卸 + `get_perf()`。⬜ 全活动图层、原始 UV/offset、源分辨率与 mip、动态角色阴影落地形、碰撞/LOD/worker/Mobile。SHINSOO §9-W1..W2/W8。 |
| E9 | 水 | 🟡 | L | W7`water_builder.cpp` 已按 `water.wtr` 逐层生成行 span 掩膜网格,`SRC_WATER` 仍是程序化波纹/菲涅耳。原客户端 parity 缺口是 `special/water/01..30.dds` 约 70ms 翻页、相机空间 UV、按水深逐顶点 alpha 和轻微高度动画。反射/折射/泡沫/normal map 属于参考帧确认后的增强;行列合并只减少几何量,不会减少现有每层一个 surface 的 draw call。SHINSOO §9-W7。 |
| E10 | SpeedTree 植被 | 🟡 | L | `.spt` 几何 loader 位于闭源 IDV `.lib`;实物为带符号的 COFF x86-64,不能链进 macOS/移动端且分发需确认授权。真实资产共 118 个。✅ R2 proxy`formats/spt.cpp` 嗅探 bark/composite atlas`tree_placeholder.cpp` 使用真实 DDS、确定性枝干、多组交叉叶簇和风摆;按 treefile 缓存共享 mesh + `MultiMeshInstance3D`A1 368 棵 / 14 树种),并已移除非原版的逐实例随机 yaw/scale。⬜ 用 `SpeedTreeRT.h` + `.lib` 验证 Windows x64 离线 exporter,再补真实 branch/frond/leaf、LOD/impostor。SHINSOO §9-W4。 |
| E11 | 地图文本格式解析(MapSetting / TextureSet / `.msenv` | ✅ | M | `formats/map_setting.cpp` + `texture_set.cpp` + `environment.cpp``.msenv` Group/List 文本树:方向光 / 材质 / `foglevel` / SkyBox+cloud / Gradient list / LensFlare)。`m2_tokvec` = `LoadMultipleTextData` 等价。CTest `formats.map_formats` + `tools/map_probe` 过真实 A14×5 / 17 层 / `foglevel 6` / 10 行 Gradient)。 |
| E12 | 区块二进制 + AreaData 解析(`height.raw` / `tile.raw` / `attr.atr` / `water.wtr` / `areadata.txt` / `areaambiencedata.txt` | ✅ | M | `formats/terrain_files.cpp`magic 2634/5426 + 尺寸校验,真值取自 `Terrain.cpp`+ `area_data.cpp`(含 `areaproperty` / `areaambiencedata`)。A1 全 20 区块 / 975 对象过 `map_probe`areadata `position` = **地图全局 cm**W3 修正,SHINSOO §5.6)。 |
| E13 | Property CRC 注册表 + 五类对象实例化(Tree / Building / Effect / Ambience / DungeonBlock | 🟡 | L | ✅ 注册表(`formats/property.cpp`A1 1330 CRC136/136 命中)。✅ Building/DungeonBlock`static_object.cpp` `get_static_mesh()`(复用 `gr2_bridge`,跨实例共享 mesh`CULL_BACK` + leaf/fence 关键字 alpha-scissor+ `place_objects()`A1 601 实例 / 119 mesh / 0 缺;位置=**地图全局 cm**,`ShadowFlag` / `portal_ids` meta / `.mdatr` 计数)。✅ Tree(W4,见 E10)。✅ `ypr` 轴共轭 bug 已修(`object_basis_godot()`,roll=朝向不再翻滚,影响 66% A1 物体;CTest 钉约定)。⬜ Effect/Ambience、逐建筑朝向对原客户端复核、`.mdatr` 碰撞体、LOD。SHINSOO §9-W3。 |
---
## F. 平台(Phase 2 —— 三设备 bring-upMac / 一加 13 / iPhone 16,自用,ML
| ID | 项 | 状态 | 规模 | 说明 |
|---|---|---|---|---|
| F0 | vendored 原生依赖 | ✅ | S | libsodium / libzstd / miniLZO 从 Homebrew 改为**源码内建**`extension/third_party/`pinned submodule + vendored miniLZO)—— 同一棵树可交叉编 NDK / iOS SDK。别名 `mt3p::sodium` / `mt3p::zstd` / `mt3p::minilzo`,无源码改动,macOS 9/9 CTest + 真实 item/mob_proto 解码逐字一致。详见 `docs/THIRD-PARTY.md`。miniLZO 是 GPL —— 自用可接受(同 oodle1.c),发布前须换。 |
| F1 | iOSiPhone 16 / Metal | 🟡 | M | ✅ 交叉编译打通:`./build-ios.sh``libmtgodot.ios.template_{debug,release}.a`arm64 / minos 15.0),全依赖(libsodium/zstd/miniLZO/libgr2/formats/mtnet/godot-cpp)随之交叉编过;iOS 强制 extension 为 STATIC(不能 dlopen),`.gdextension` 加了 `[dependencies] ios.*` 归档清单。⬜ 剩 Godot 4.7 iOS 导出模板 + 导出 preset 链 `project/bin/ios/*.a` + 免费 provisioning 签名 + iPhone 16 真机跑 + Release/strip。预留 23 天。详见 `docs/PLATFORMS.md`。 |
| F2 | Android(一加 13 / Vulkan | 🟡 | M | ✅ CMake 已接 `CMAKE_SYSTEM_NAME==Android`SHARED `.so` + `arm64` 名字后缀);`./build-android.sh` 走 NDK 自带 `android.toolchain.cmake``arm64-v8a` / `android-24`)。⬜ 本机无 NDK —— 装 NDK 后 `./build-android.sh``.so`,再 Godot Android 导出 presetGradle+ 一加 13 真机跑。**只验 Vulkan**,不做 Compatibility(GLES3) / 老机。详见 `docs/PLATFORMS.md`。 |
| F3 | 三端一致性 | ⬜ | S | 同工程 / 同 t / 同机位截图,iPhone 16 · 一加 13 · Mac 对齐。 |
| F4 | 移动端性能 / 帧预算 | 🟡 | M | ✅ **GPU 纹理压缩**`extension/src/texture_util.{h,cpp}` `make_color_texture()` —— 移动端(`OS.has_feature("mobile")`)或 `MTGODOT_TEXCOMP=1` 时对颜色贴图运行时 ASTC 8x8~2bpp vs RGBA8 32bpp),编码失败回退 RGBA8;桌面默认关,逐调用点保留原 mipmap 标志 → OFF 时与旧路径逐字节一致。接入:角色皮肤(`_load_dds`)、建筑 albedo`static_object`)、树皮/树冠(`tree_placeholder`);splat 控制图 / 阴影图 / 水 / HUD 保持 RGBA8。macOS+iOS 编过,ctest 9/9,桌面渲染无变化,`MTGODOT_TEXCOMP=1` 角色渲染正常。⬜ 剩:一加 13 / iPhone 16 单 + N 角色 bench`MTGODOT_GPUSKIN=1` 人群)、构建成本(~170ms/角色,A5/G2)、移动端画质核对。 |
| F5 | App 生命周期 | 🟡 | SM | ✅ `project/app_lifecycle.gd`AppLifecycle 节点):把 `NOTIFICATION_APPLICATION_PAUSED/RESUMED` / `FOCUS_IN/OUT` / `OS_MEMORY_WARNING` / `WM_GO_BACK_REQUEST` 收成信号,默认动作 = 后台暂停树 + 停 BGM + 降 `max_fps`,前台还原;`bind(m2client, audio)` 一键接。`M2Client`C++):`_notification` 收 PAUSED/RESUMED → `suspend()/resume()`(挂起时不 pump socket),`reconnect()` 用存好的凭据重登;新信号 `suspended`/`resumed`。已接 `login.gd`(回前台若在游戏中自动重连)+ `world_demo.gd`。headless smokesuspend/resume/reconnect 绑定 + 幂等 + 信号)通过,ctest 9/9,iOS 编过。渲染上下文丢失由 Godot Vulkan 自恢复,GPU 资源全 RenderingServer 托管,无需处理。⬜ 剩真机验证后台 30s→掐连接→回前台重连、iOS 内存告警实际触发。 |
| F6 | 打包 | ⬜ | SM | macOS / iOS / Android export preset、资产打包策略、iOS 签名。自用,无商店上架。 |
| F8 | 实体点选精度 | ✅ 首版 | S | `player_controller._ray_pick_t()`:射线 vs 每个 pickable 下所有 `MeshInstance3D` 的**世界 AABB 合并 + slab 相交**(逐实体精确,大怪 / 小怪不再被固定 1.4 m 半径误判;`grow(0.15)` 宽容度)。无网格回退旧胶囊近似。对齐 `EterGrnLib/ModelInstanceCollisionDetection::Intersect` 的**结果**,不复刻逐三角。`eterngrn_polish_test.gd` 覆盖(命中 t / 未命中 -1 / 擦边 / 无网格回退)。⬜ 逐三角 / 骨骼包围体、`GetHeight`。 |
| F7 | 触屏输入 / 手势相机 | ✅ | S | `game_camera.gd`:单指拖拽环绕(8px deadzone,低于视作点击)/ 双指捏合缩放;`player_controller.gd`:单指轻点(≤12px 位移 & ≤0.35s)= 点地 / 点选,拖拽或双指手势时不触发。`project.godot``emulate_mouse_from_touch`(避免与轻点重复触发),桌面鼠标路径不变。两脚本 `--check-only` + 项目导入无错。 |
| F8 | `godot-cpp` 钉稳定版 | ✅ | S | gitlink 精确锁定 `101ae38034304346a46ea9ea84ae156d3e860496``.gitmodules` 已移除移动的 `master` branch 配置;目标 API 固定为 Godot 4.7。 |
---
## G. 资产管线(SM
| ID | 项 | 状态 | 规模 | 说明 |
|---|---|---|---|---|
| G1 | eterpack 读取器 | 🟡 | ML | ✅ **本 fork 不是老 `.eix/.epk`** —— 是 `PackLib/Pack.cpp` 的单文件包(header + XChaCha20 加密索引 + zstd 数据,可选逐文件加密,硬编码 `PACK_KEY`)。`extension/src/pack/eterpack.{h,cpp}``EterPack`(读,name-field 从 header 反推以兼容真实 `PackMaker.exe` 包)+ `write_pack`(写)+ `packtool` CLIpack/unpack/list,跨平台 PackMaker 替代)。`pack.roundtrip` CTest + 真实 45 文件 `textureset/` 目录明文/加密往返 `diff` 一致(含 CP949 中文名 + 嵌套目录)。⬜ 接进 `AssetResolver`(从包解析而非散文件)+ mmap 大包。 |
| G2 | 跨实例资产缓存 / 去重 | 🟡 | M | 角色:每实例重建。世界(W8):`Metin2World``PropertyRegistry` / `AssetResolver` / `StaticMeshCache`(建筑 gr2→meshA1 119 份)/ DDS 解码缓存跨区块装卸复用。SHINSOO §9-W8。 |
| G3 | 异步 / 多线程加载 | 🟡 | M | 角色 `Metin2Model` 全同步。世界(W8):`Metin2World` 流式装载分摊到 `stream_budget` 个区块/帧(主线程),未上 worker thread。SHINSOO §9-W8。 |
| G4 | `.dds` 原生 import 路径 | 🟡 | S | M1 T1.3 定为运行时 `dxt.cpp`level 0 + `generate_mipmaps()`)。Godot 原生 import 分支没接。 |
| G5 | GPU 压缩纹理 / mip 质量 / sRGB 通盘过 | ⬜ | SM | 现在软解 RGBA8 上传,GPU 端不压缩;移动端显存 / 带宽要压(BC7 / ASTC)。 |
| G6 | DXT 软解补 level-N mip | 🟡 | S | mtgodot `dxt.cpp` 只解 level 0xrender 版有完整 mip 链,可回移)。 |
| G7 | 跨 pack 虚拟路径索引与覆盖优先级(`AssetResolver` | 🟡 | M | ✅ `formats/asset_resolver.cpp`:规范化(`\``/` / 去盘符 / 小写 / `.``..`+ `ymir work/` 后缀索引 + `by_rel` + pack 优先级(基础包在前,`metin2_patch_*` 在后,`stable_sort` + 先注册者胜)+ 冲突计数。A1 索引 54212 文件、17/17 地表贴图可解析。⬜ 冲突明细导出 + 优先级快照进测试。是 G1(eterpack)的运行时前置。SHINSOO §4.2 / §9-W0。 |
---
## H. 测试 / 工程(SM
| ID | 项 | 状态 | 规模 | 说明 |
|---|---|---|---|---|
| H1 | 全语料过 Godot loader 的 fuzz | ⬜ | SM | `gr2fuzz` 只覆盖 libgr2 解析(9166/9166)。缺「把每个 `.gr2` 塞进 `Metin2Model` + 蒙皮 + draw」的冒烟(对标 xrender 的 `render_fuzz`)。 |
| H2 | 全 race / 怪物 / NPC / 坐骑抽样 | 🟡 | S | 现在只抽查 ~6 个 PC 模型 + `anim_probe` 4 个 race。 |
| H3 | 带容差的 golden 视觉回归 + CI | 🟡 | M | `compare.py` 已对 Godot 非零退出、超时、旧/缺截图、0 surface、缺动画自检和 NaN 作硬失败;CPU/GPU 像素差仍是 advisory。缺校准后的结构化像素 / 直方图门禁 + 可截图的 CI renderer。 |
| H4 | 扩展侧 dump 骨骼矩阵 / 顶点做对拍 | ⬜ | S | 现在数值对拍靠 libgr2 层的 `oracle_diff`。加 `MTGODOT_DUMP=<path>` 从扩展导出,闭合「mtgodot == Granny」全链。 |
| H5 | Android NDK 上构建 tools | ⬜ | S | `gr2dump` / `gr2fuzz` / `oracle_diff` 目前 macOS。 |
| H6 | 逐材质 oracle 对拍(纸面确认) | ⬜ | S | `material_textures` 已按渲染结果验(warrior/sura/assassin/shaman 全对),缺 `run-diff-suite.sh` 级别的逐材质数值确认。 |
| H7 | A1 地图格式 / 数值 / golden 回归 | ⏭ | M | 地图数据无外部 oracle(不像 `.gr2` 有 Granny DLL):移植 `Terrain.cpp`/`Area.cpp` 公式做自洽 + `minimap.dds` 目视 + 受控机位 golden。`formats/tests/map_formats_test.cpp` 进 CTest`world-a1-r1/r2.png``compare.py`。SHINSOO §11。 |
---
## I. libgr2 覆盖面(SM
| ID | 项 | 状态 | 规模 | 说明 |
|---|---|---|---|---|
| I1 | >4 骨权重 / 8-bone-weight | ✅ | S | `libgr2::Mesh` 已记录源 `BoneWeights` 槽数,`gr2fuzz` 纳入直方图。9166 文件中的 3118 个 skinned mesh 全部声明 4 槽,当前语料无需 `ARRAY_FLAG_USE_8_BONE_WEIGHTS`。新资产若出现其它槽数会在报告中显现。 |
| I2 | 多骨架 `.gr2` | ⬜ | SM | libgr2 的 `extract_mesh` **会**按 BoneBindings 悬空最少给每个 mesh 选对骨架。缺口在桥接:`Metin2Model` 只把 `fi.skeletons[0]` 实例化成 `Skeleton3D` —— 绑到 `skeletons[1]`(武器 / 挂点骨架)的 mesh 会错绑。需按 mesh 的实际骨架建多个 Skeleton3D 或合并。 |
| I3 | 变形目标 / morph | ⬜ | S | libgr2 不解析 `granny_mesh.MorphTargets`。Metin2 用骨骼驱动面部,大概率 N/A —— 确认后标 ❌ 或补。 |
| I4 | 游戏相机 | 🟡 | M | 世界(W6):`project/world_demo.gd` 第三人称跟随(yaw/pitch/dist + 沿视线 `sample_height` 防穿)+ WASD + 贴地 + `attr.atr` 阻挡(逐轴)+ wait/walk/run 状态机 + NPC `Label3D`。⬜ 鼠标转视角交互壳、遮挡淡出、机位 preset;`SpringArm3D`/物理射线要完整覆盖建筑,先依赖 E13 的 `.mdatr`/静态对象碰撞体,当前地形碰撞只能解决地形防穿。SHINSOO §9-W6。 |
| I5 | 地图 / 全局 / Godot 坐标统一转换模块(`mtgodot::coord` | 🟡 | S | ✅ `formats/m2_coord.{h,cpp}` 纯数学核心:`position_to_godot` / `direction_to_godot` / `tile_id` / `tile_dir` / `chunk_origin_cm` / `height_raw_to_cm`,与 `make_conv``-90°X` + `0.01`)一致,CTest 覆盖。✅ `ypr_basis`Metin2 空间,D3D 端口)+ `object_basis_godot`(共轭到 Godot Y-up,放置用;修了漏轴转换 bug),CTest 覆盖。⬜ extension 侧 godot 薄封装。SHINSOO §6。 |
---
## 已明确不做(本 POC`GODOT-POC-PLAN.md` §01
UI / Python 脚本层、多角色 AI、网络、玩法逻辑、eterpack(Phase 1 用散文件 —— 但 G1 是正式移植必做)。
`.mse` 特效 / 地形 / 水 / SpeedTree 在 E 段标 `⏭`:正式移植必做、独立立项,但**不影响 Phase 1 中期评审结论**,也不阻塞 Phase 2 的三设备 bring-up。
---
## 排期
> 规模粗估、置信度低(见开头);下面的「周」是相对量级,不是承诺。
### Sprint 1 —— 小项收尾(~1 周)
已完成:`A3` · `A6` · `F8` · `I1`,并补完 `A9`/`A10` 正确性问题。
剩余:`A5`CPU 路径原地更新)· `A7`(前台 Metal stress 基线)· `A8`+`H1`(桥接层畸形用例与全语料 draw 冒烟)。
### Sprint 2 —— 组合基础(~2 周)
`A2` 已完成。剩余:`A1`(发型挂接,M)· `A4`(静态路径统一)· `I2`(多骨架排查)。
### Phase 2 —— 平台(三设备 bring-up,自用)
`F1``F7`:一加 13Vulkan+ iPhone 16Metal,静态链接)跑起来 + 三端一致 + 两台真机性能留档(旗舰基线)+ 生命周期 + 触屏相机。**无 Compatibility / 老机、无对外 go/no-go。**
### Phase 2 之后(正式移植,粗排,各阶段数周–数月)
1. **材质保真** `B1`(扩 libgr2 API)→ `B2``B11` · `H6`
2. **动画 + 组合** `C2`/`C3`/`C4`/`C5`/`C6` · `D1``D4`
3. **资产管线** `G1``G3` · `G5`(移动端纹理压缩)。
4. **独立子项目([`SHINSOO-WORLD-RENDERING.md`](./SHINSOO-WORLD-RENDERING.md)Phase 2 bring-up 之后)**
`G7`+`E11`+`E12`+`I5`W0 路径 / 格式 / 坐标)→ `E8`(地形 geometry+splat)→ `E13`Property 对象)→
`E10`(树)→ `E2`(环境)→ `I4`(相机)= R1;再 `E9`(水)+ `E7`(必要 `.mse`+ UI + `H7` = R2。
5. **打磨** `C8`IK)· `C9`LOD)· `E1`/`E2`/`E3`/`E5`(阴影 / 环境 / 后处理 / 透明排序)· `H3`CI)· `I4`(游戏相机)。
+495
View File
@@ -0,0 +1,495 @@
# m2dev-client → mtgodot-poc 1:1 功能差距
> 对照范围:`../m2dev-client-src-main/`(客户端源码)、`../m2dev-client-main/`
>(可执行客户端与配置)以及本工程当前实现。
>
> 本文按“协议、数据、交互、表现均与原客户端等价”的严格 1:1 标准审计;
> `CLIENT-ROADMAP.md` 中的“P0–P10 首版完成”只表示主要纵向流程已有首版,不等于 1:1 完成。
> 渲染细节另见 `PARITY-GAP.md`,动画、材质和资产管线另见 `BACKLOG.md`,网络实现另见
> `CLIENT-PORT.md`。
>
> 更新日期:2026-08-31(增量 38
>
> **状态列图例**:✅ 达到本文末“1:1 完成判据” · 🟡 有首版 / 部分语义 · ⬜ 未实现 ·
> ❌ 明确不做(超出 m2dev-client 参考范围,除非另立需求)。
>
> ### 不做清单(❌)
>
> - **拍卖行 / auction house**m2dev-client 无此系统 —— `0x08xx` 段只有
> `SHOP`/`MYSHOP`/`SAFEBOX_*`/`MALL_*`,全源码 / 协议头 / 资产零 `auction` 命中。
> 要做即“新造协议 + 配套服务端”,不属 1:1 移植。**标注为不做**,除非后续明确提需求。
> - **反作弊(anti-cheat**`CG_HACK`(0x0B03) 客户端异常上报,以及桌面外壳的
> `ProcessCRC` / `ProcessScanner` / 内存扫描等。自用客户端不需要,**标注为不做**,
> 除非服务端强制校验才回头做最小上报。
>
> ### 变更记录
>
> - **2026-08-31 增量 30EterGrnLib 对齐复核)**:查代码确认以下已落地,表内相关行同步。
> ① **材质 blend / two-sided 改读 GR2 材质信息**`metin2_model::decide_blend/decide_two_sided`
> 优先 `gr2::MaterialInfo.alpha_blend/two_sided` = `dump_materials` 端口 `Material.cpp:233`
> body + weapon 两处;`m2_material` 加 `cull_disabled` shader 变体)。
> ② **LOD 淡入**`LodGhost` 冻结旧 mesh + `lod_fade` uniform0.18s,距离阈值 15% 迟滞)。
> ③ **crossfade 权重改 ease-in `2w²−w³`**,对齐 `GrannySetControlEaseInCurve(0,0,1,1)`。
> ④ **`.msa` motion event 类型分派**`game_scene._on_local_motion_event`sound / effect /
> SCREEN_WAVING 震屏 / SCREEN_FLASHING 屏闪 / FLY·WARP 钩子)。
> ⑤ **角色接触阴影**`project/ui/char_shadow.gd`:强制 `cast_shadow=ON` + 脚下渐变 Decal
> `player_view` / `mob_view` 调用)。
> ⑥ **点选精度**`player_controller._ray_pick_t`:射线 vs 子 mesh 合并世界 AABB slab 相交,
> 无 mesh 回退胶囊)。
> ⑦ **武器挂点复核**:隔离探针实测 weapon MI 原点 == `equip_right_hand` 骨骼原点
> Δ≤0.06 m(旧「偏 10 m」为 cpu_skin / MapCoord 修复前的失效结论);新增 grip
> pre-transform `weapon_pre = mul4x3(weaponInvWorld[0], weaponLocal[0])`(对齐
> `ModelInstanceUpdate.cpp:171` 的 `weaponComposite[0]`),30 cm 位移夹断退回原始手部位姿。
> `03150` 类网格在 .gr2 里就离骨骼原点 ~3 m(资产缺陷,非变换)。
> 新增测试 `eterngrn_polish_test.gd``ctest 10/10` + 29 套 GDScript + iOS 全绿。
> - **2026-08-31 增量 38ESC 系统菜单 + 游戏设置窗)**:
> ① **`system_menu_ui.gd`**`systemdialog.py` 真 uiscriptthinboard 8 键)= `uisystem.py`
> `SystemDialog.__LoadSystemMenu_Default` 的 1:1`system_option_button` → `system_option_ui.open()`
> `game_option_button` → `game_option_ui.open()``change_button` → `client.say(0,"/phase_select")`
> = `net.ExitGame()` 里 `SendChatPacket("/phase_select")`),`logout_button` → `"/logout"`
> = `net.LogOutGame()`),`mall_button` → `"/in_game_mall"``exit_button` → `get_tree().quit()`
> `help_button` → 占位提示,`cancel_button` / 标题栏 → 关。ESC 现在开这个菜单(`game_scene`
> 把 ESC 从直接开 system_option 改成开 system_menu)。
> ② **`game_option_ui.gd`**`gameoptiondialog.py` 真 uiscript= `uigameoption.py`
> `block_{exchange,party,guild,whisper,friend,party_request}_button`(toggle) →
> `client.say(0,"/setblockmode " + str(mask ^ bit))``EBlockAction` 位 1<<0..1<<5,本地
> `_block_mode` 跟踪 + 持久化,服务器回包同步待补);`pvp_{peace,revenge,guild,free}`(radio) →
> `"/pkmode {0,1,4,2}"``__OnClickPvPMode*`);`name_color`/`target_board`/`view_chat`/
> `always_show_name`/`show_damage`/`salestext` radio → 持久化 `user://system_option.cfg [gameopt]`
> `systemSetting.Set*Flag` 等价;渲染侧钩子待补)。
> 新增 `system_menu_ui_test.gd`(两窗装载 + `/setblockmode` 位运算 + `/pkmode` + `/phase_select`/
> `/logout`/`/in_game_mall` + 显示开关持久化 + 二次实例读 cfg)。C++ 无改动;`ctest 10/10` +
> 34 套 GDScript + 导入 + iOS 全绿。
> - **2026-08-31 增量 37(组队成员信息板 + `CG_PARTY_SET_STATE`**
> ① **C++**`wire.h` 加 `CGPartySetState{u16 hdr,u16 len,u32 pid,u8 role,u8 on}`(断言 10
> + `EPartyRole` 枚举(NORMAL 0/LEADER 1/ATTACKER 2/TANKER 3/BUFFER 4/SKILL_MASTER 5/
> BERSERKER 6/DEFENDER 7);`game_client.send_party_set_state(pid,role,on)`
> `M2Client.party_set_state(pid,role,on)` + bind`get_party()` dict 补 `state`(完整角色字节)
> 和 `affects[7]`。
> ② **`party_ui.gd`** 从「名字 + 一条 HP」重做成 1:1 迁移 `assets/root/uiparty.py`
> `PartyMemberInfoBoard` + `partymemberinfoboard.py` 布局:每员 strip = 角色状态按钮
> (队长可点 → 弹角色菜单 普通/攻击/坦克/狂战/辅助/宗师/防御 → `party_set_state`;普通 =
> 清当前角色 `on=false`+ 踢出 → `party_leave` = `SendPartyRemovePacket`+ 名字(+★队长)
> + HP gauge + `affects[7]` 非零 → 附加效果 chip(tooltip 带值,槽位名暂定)。点名字 →
> `set_target(vid)`。顶部:EXP 分配开关(`party_set_distribute` 0 不均分 / 1 均分)+
> 组队治疗(`party_use_skill(PARTY_SKILL_HEAL=1, 0)`)。队长判定 = `get_party()` 里本地
> `get_main_vid()` 那条的 `leader`。角色菜单按党技能等级门控(Tanker≥10…Defender≥40)暂略。
> ③ **修 `p8_test` 隐患**`_init` 原来 `_run()` 未 `await``_run` 第一个 `await process_frame`
> 之后的所有断言(含增量 34 的 `shop_ex` 多货架)从未真正生效 —— 改成 `await _run()` 后
> 暴露 `shop_ui.refresh()` 的 `queue_free` 延迟 bug(旧 row 当帧未消失 → `get_child_count`
> 多计),已改 `remove_child` + `queue_free`。
> `ctest 10/10` + 33 套 GDScript + 导入 + iOS 全绿。
> - **2026-08-31 增量 36(系统设置窗 + 私人商店/选魔石补真 uiscript**
> ① **系统设置窗** `project/ui/system_option_ui.gd` —— `UiScript`+`UiBuild` 装
> `assets/uiscript/uiscript/systemoptiondialog.py`;控件绑定逐字对照
> `assets/root/uisystemoption.py``music/sound_volume_controller`(sliderbar) →
> `snd.SetMusicVolume/SetSoundVolume`(接 `Audio.master_bgm/master_sfx` + 即时改在播 BGM 声道);
> `camera_short/long`(radio) → `game_camera.max_dist`(近 11 / 远 20,并夹当前 dist);
> `fog_level0/1/2`(浓/中/淡 radio) → `Environment.fog_enabled` + `fog_density`
> `[0.055/0.018/0.004]``tiling_cpu/gpu`+`apply`(原 CPU/GPU 分块渲染)Godot 渲染器无对应,
> 保留占位。设置持久化到 `user://system_option.cfg`= 原 `systemSetting` 配置文件),
> `setup()` 时读回即时生效,`.msenv` 换 `Environment` 后 `_on_main_set` 重新 `_apply_all`。
> `game_scene`ESC 呼出(`ui_manager` 先关最顶层窗并吃掉事件 → 到 `game_scene` 说明无窗打开)。
> 新增 `system_option_ui_test.gd`(装载 + 音量/镜头/雾绑定 + cfg 持久化 + 二次实例读 cfg)。
> ② **私人商店窗改走真 uiscript** `privateshopbuilder.py`board + TitleBar + NameLine +
> `ItemSlot` 5×8=40 grid + Ok/Close):40 格从 `_win.nodes` 索引,`NameLine`(text) 上盖
> `LineEdit`,背包候选面板挂窗右侧(原客户端靠主背包拖拽,此处点选)。`itemStock` 交互模型
> (拿起 / 落位 / 价格弹窗 / 排序 + display_pos / 撤下 / 개설 / 철수)不变。测试拆出独立
> `private_shop_ui_test.gd`uiscript,缺资产跳过),`shop_cube_mall_test` 私人商店段移除。
> ③ **选魔石窗** `select_item_ui.gd` 早已走真 uiscript `selectitemwindow.py`(增量 33),无需改。
> C++ 无改动;`ctest 10/10` + 33 套 GDScript + 导入 + iOS 全绿。
> - **2026-08-31 增量 35(私人商店开设窗 = `PrivateShopBuilder` 交互模型)**`private_shop_ui.gd`
> 从「前 5 行 checkbox」重做成 1:1 复刻 `assets/root/uiprivateshopbuilder.py` 的
> `itemStock` 模型:左侧背包候选(点一件“拿起”)+ 右侧 40 格(`shop.SHOP_SLOT_COUNT`5×8);
> 拿着件点空格 → 价格输入(`uiCommon.MoneyInputDialog`)→ 落位(`AddPrivateShopItemStock` +
> `itemStock[targetSlot]=(src)`);点已占用格 → 撤下(`OnSelectItemSlot` /
> `DelPrivateShopItemStock`)。개설(OkButton) 把 stock 按格号排序、`display_pos = 格号`、
> 上限 39`PRIVATE_SHOP_ITEM_MAX_NUM` = `TPacketCGMyShop::bCount` cap)打包成
> `M2Client.open_private_shop(sign, [{vnum,count,inv_cell,price,display_pos}])`
> = `TPacketCGMyShop` + `TShopItemTable`×N,字段 1:1)。`shop_cube_mall_test` 私人商店段
> 重写(拿起 / 落位 / 价格弹窗 / 排序 + display_pos / 撤下 / 철수)。C++ 无改动;
> `ctest 10/10` + 31 套 GDScript + 导入 + iOS 全绿。
> NOTE: 面板仍手绘(与 mall_ui / cube_ui 一致),真 `.sub` 版 `privateshopbuilder.py` uiscript 待后续。
> - **2026-08-31 增量 34`SHOP_GC_START_EX` 多货架商店)**`entity_store` 新增
> `SHOP_GC_START_EX`(10) 解析 —— `TPacketGCShopStartEx{u32 owner_vid, u8 tab_count}`
> + `tab_count` × `{ShopTabHead(char name[32]+u8 coin_type=33B), ShopItem[40]}``wire.h`
> 加 `ShopTabHead` + 断言 33`SHOP_TAB_NAME_MAX=32`/`SHOP_TAB_COUNT_MAX=3`)。
> `ShopEntry` 加 `pos`(货架内槽位,买位置用),`ShopTab{name,coin_type,items}` +
> `m_shop_tabs``SHOP_GC_START` 也改成按固定 40 槽数组填 `pos``SHOP_GC_END` 清货架。
> `shop_items()` 仍是 tab 0 的镜像(旧调用不动)。`M2Client.get_shop()` → `{vid, open,
> tabs:[{name, coin_type, items:[{pos,vnum,price,count}]}]}`。`shop_ui.gd``tabs>1` 显示
> 货架按钮,买位置 = `tabIdx*SHOP_SLOT_COUNT(40)+slotPos`1:1 对齐 `uishop.py`
> `GetIndexFromSlotPos`)。`net_entity_test` 加双货架合成包断言(名称 / coin_type / 槽位保留 /
> tab0 镜像 / END 清空),`p8_test` 加多货架 UI + `buy pos 5` / `buy pos 43` 断言。
> `ctest 10/10` + 31 套 GDScript + 导入 + iOS 全绿。
> - **2026-08-31 增量 33(选魔石窗,样板第 2 例)**:新增 `project/ui/select_item_ui.gd`
> ——`UiScript`+`UiBuild` 装载 `assets/uiscript/uiscript/selectitemwindow.py`5×8
> `grid_table`);填格逻辑逐字照 `assets/root/uiselectitem.py.RefreshSlot`:遍历背包前
> `INVENTORY_PAGE_SIZE*2` 格,只留 `item.IsMetin``proto.type == ITEM_TYPE_METIN` = 10
> 且 `GetItemGrade <= 2`= 物品**内部名**最后一位数字,非数字按 0)的物品,最多 54 个。
> 点格 → `client.script_select_item(inventoryCell)`= `net.SendSelectItemPacket`
> `CG_SCRIPT_SELECT_ITEM` 0x0903+ 关窗;ExitButton / 关闭 → `script_select_item(0)`
> `uiselectitem.Close`)。触发链 1:1`quest_dialog.parse_script` 识别 EventManager
> `[SELECT_ITEM]` token → 新增 `select_item_requested` 信号(对应 `PythonEventManager`
> `EVENT_TYPE_SELECT_ITEM` → `interfacemodule.BINARY_OpenSelectItemWindow`)→
> `game_scene` 接到 `select_item_ui.open()`。新增 `select_item_ui_test.gd`token 解析 +
> 过滤 + 选包 + 关窗发 0);`ctest 10/10` + 31 套 GDScript + 导入 + iOS 全绿。
> - **2026-08-31 增量 32(角色状态窗 = 1:1 窗口迁移样板)**:新增
> `project/ui/char_status_ui.gd`——直接用 `UiScript`+`UiBuild` 装载
> `assets/uiscript/uiscript/characterwindow.py` 真布局;数值绑定逐字对照
> `assets/root/uicharacter.py.RefreshStatus`Level/Exp/RestExp、HP/SP、
> STR/DEX/HTH/INT、ATT=`(min|max)+ATT_BONUS+ATTACKER_BONUS`、
> DEF=`DEF_GRADE(+DEF_BONUS)`、MATT=`MAG_ATT+(min|max)MAGIC_WEP`、MDEF、
> ASPD/MSPD/CSPD/EREPointTypes 索引取自 m2dev `Packet.h`)。加点 / 按钮发
> `client.say(0,"/stat ht"|"/stat- ht" …)`,与原 `statusPlusCommandDict` /
> `statusMinusCommandDict` 完全一致;`POINT_STAT>0` 才显示加点按钮。STATUS/SKILL/
> EMOTICON/QUEST 四页做 `SetState` 页签切换(技能 / 表情 / 任务的数据仍在各自专用窗)。
> 名称 / 帮会 / 职业头像取 `get_entity(get_main_vid())`。`game_scene` 挂 V / C 热键。
> 新增 `char_status_ui_test.gd``ctest 10/10` + 30 套 GDScript + 导入 + iOS 全绿。
> 这是「原 `interfacemodule.py` 逐窗口迁移」的样板,后续选道具网格 / 商店 START_EX /
> 私人商店 39 格照抄这套(uiscript 装载 + 逐字数值绑定 + 原聊天命令 / 封包)。
> - **2026-08-31 增量 31(真服 E2E 二轮扫,单会话)**:以
> `MT_NET_TRACE=1 MT_E2E_SWEEP=1 ./build/extension/net_e2e` 完成一次
> auth→选人→PHASE_GAME 会话;出站明文 / 加密字节均已落盘。10 个安全探针全部发送成功、
> 450ms 后仍在线、`unknown_header=0``CG_CHARACTER_POSITION`(5B)、
> `CG_SYNC_POSITION`(16B)、`CG_SCRIPT_SELECT_ITEM`(8B)、`CG_QUEST_CANCEL`(4B)、
> `CG_PARTY_USE_SKILL`(9B)、`CG_FLY_TARGETING`/`CG_ADD_FLY_TARGETING`(16B)、
> `CG_FISHING`(5B)、`CG_SHOOT`(5B)、`CG_USE_SKILL`(12B)。`CG_QUEST_CANCEL` 触发 1 条
> quest-change;钓鱼收到真服 `Please choose a Fishing Pole.`(无鱼竿的预期业务拒绝),其余
> 无业务事件但均无断连 / 无解析错误。被动 `GC_LAND_LIST` 436B 在登录洪流中真服收到并解析。
> 扫描暴露一个代码缺口:`GC_FLY_TARGETING`/`GC_ADD_FLY_TARGETING` 与
> `GC_CHANGE_SKILL_GROUP` 已有 `EntityStore` 实现却未列入 `GameClient` 世界分派,现已补路由并由
> `net_entity_test` 复核。`CG_DUNGEON`、`CG_WARP`、改名 / 符号 / 物品变更等破坏性或独立
> 连接协议本轮未发送,仍保持“待真服验”。
> - **2026-08-31 增量 5(实现复核)**:修正文档中已经落后的功能状态。
> **情侣状态**已不再是“整体未做”:`GC_LOVER_INFO`(70B) / `GC_LOVE_POINT_UPDATE`(5B)
> 已接入 `EntityStore`,并通过 `M2Client.get_lover()` / `lover_changed` 驱动常驻
> `love_ui`C++ 状态测试和 `love_ui_test.gd` 均有覆盖;仍缺完整情侣交互与真服验收。
> **角色 / 装备渲染**已补本地主角的身体盔甲 / 时装 parts、头盔 / 假发 / 发型、右手武器、
> 左手盾牌、`item_proto.specular/100` 球面高光、角色 LOD 迟滞 + 0.18s 淡入、动作切换
> ease-in、`.msa` 音效 / 特效 / 震屏 / 屏闪事件和角色接触阴影首版。**地图 / 材质**行同步
> 记录 16 层 splat、原 UV / 源分辨率、水纹序列 / 水深 alpha、terrain patch 剔除和物体
> Y-up 旋转修复。协议审计总缺口仍为 **24 个 header**,但 `GC_LOVER_*` 已从“声明未消费”
> 清单移除;输入行修正为已支持 F1–F4 与数字键 1–9。
> 本次同时补充“参考模块覆盖索引”,把协议表之外的聊天、TextTail、HUD、UI 运行时、输入 / 相机、
> 登录串场和桌面外壳差距单独列出。
> - **2026-08-31 增量 6(位置同步上行)**:补齐 `CG_CHARACTER_POSITION`(0x0A60) /
> `CG_SYNC_POSITION`(0x0303) 的 wire 结构、尺寸断言、`GameClient` 发送接口和
> `M2Client.character_position()` / `sync_positions()` API;回环测试覆盖姿态包、最多 16 个
> 坐标元素、字段值和空批次 / 超限拒绝。两项已从“缺失 header”移入“已声明但待核”——仍需
> 接入原版的自动碰撞纠偏时机、姿态动作和真服务器端到端验收。
> - **2026-08-31 增量 7(任务应答协议)**:补齐 `CG_SCRIPT_SELECT_ITEM`(0x0903) /
> `CG_QUEST_CANCEL`(0x0906) 的 wire 结构、尺寸断言、`GameClient` 发送接口和
> `M2Client.script_select_item()` / `quest_cancel()` API;回环测试验证选择值与取消包。
> 同时让 `QuestDialog` 支持基础 `[INPUT]` 文本框和 Escape 取消;缺失 header 清单由 22 项
> 降为 20 项。任务窗口仍需接入选道具网格、原版 IME / 输入校验和完整 `EventManager` 标签语义。
> - **2026-08-31 增量 8(组队技能上行)**:补齐 `CG_PARTY_USE_SKILL`(0x0705) 的 wire
> 结构、尺寸断言、`GameClient.send_party_use_skill()` 和 `M2Client.party_use_skill()`
> 回环测试验证技能索引 / 目标 VID。缺失 header 清单由 20 项降为 19 项;队伍技能的
> 冷却、权限和队员世界标记仍待补齐。
> - **2026-08-31 增量 9(飞行目标广播)**:补齐 `GC_FLY_TARGETING`(0x0411) /
> `GC_ADD_FLY_TARGETING`(0x0412) 的 20B wire 结构;`EntityStore` 持久化 shooter 的目标
> VID / 坐标并产生追加标记,`M2Client.fly_targeting` 信号向 Godot 暴露;C++ 实体测试覆盖
> 设置目标、追加目标和坐标回退。缺失 header 清单由 19 项降为 17 项。
> - **2026-08-31 增量 10(技能组切换)**:补齐 `GC_CHANGE_SKILL_GROUP`(0x021D) 的 5B
> wire 结构、`EntityStore` 技能组状态 / dirty 标记、`M2Client.get_skill_group()` 和
> `skill_group_changed` 信号;切换会同时使技能快照失效,C++ 实体测试覆盖状态更新。
> 缺失 header 清单由 17 项降为 16 项。
> - **2026-08-31 增量 11(角色改名)**:补齐 `CG_CHANGE_NAME`(0x010B) /
> `GC_CHANGE_NAME`(0x010C) 的固定长度 wire 结构、`GameClient` 改名发送与角色槽位更新、
> `M2Client.change_name()` 及 `char_name_changed` 信号;回环测试覆盖名称字节和 pid 广播。
> 缺失 header 清单由 16 项降为 14 项。
> - **2026-08-31 增量 12(公会创建请求)**:补齐 `GC_REQUEST_MAKE_GUILD`(0x0731) 的
> 无负载包解析,`GameClient` 产生请求事件,`M2Client.guild_make_requested` 向 Godot 暴露,
> 回环测试覆盖事件消费。缺失 header 清单由 14 项降为 13 项。
> - **2026-08-31 增量 13(主动传送请求)**:补齐 `CG_WARP`(0x0305) 的 4B wire 结构、
> `GameClient.send_warp()` 和 `M2Client.request_warp()`;回环测试验证上行请求及现有
> `GC_WARP` 目标回传。缺失 header 清单由 13 项降为 12 项。
> - **2026-08-31 增量 14(挖矿动作广播)**:补齐 `GC_DIG_MOTION`(0x0308) 的 13B wire
> 结构、`EntityStore` 挖矿动作事件、`M2Client.dig_motion` 信号和 `NetWorld` 朝向 / 动画
> 元数据处理;实体、回环和 Godot 桥接测试覆盖。缺失 header 清单由 12 项降为 11 项。
> - **2026-08-31 增量 15(公会符号下载)**:补齐 `CG_SYMBOL_CRC`(0x0723) / `GC_SYMBOL_DATA`(0x0732)
> 的 16B / 8B 结构和原始数据流;`MarkClient` 支持 CRC 请求与变长符号文件接收,
> `M2Client.download_guild_symbol()` / `get_guild_symbol()` / `guild_symbol_ready` 已接入,
> 回环测试覆盖 CRC 字段、guild id 和数据长度。缺失 header 清单由 11 项降为 9 项。
> - **2026-08-31 增量 16(钓鱼协议)**:补齐 `CG_FISHING`(0x0B01) / `GC_FISHING`(0x0B10)
> 的 5B / 10B wire 结构、旋转方向换算、`EntityStore` 事件和 `M2Client.fishing()` /
> `fishing_event`;回环与 Godot 桥接测试覆盖。缺失 header 清单由 9 项降为 7 项。
> - **2026-08-31 增量 17(副本协议)**:补齐 `CG_DUNGEON`(0x0B02) / `GC_DUNGEON`(0x0B11)
> 的 4B / 5B wire 结构,支持目的地坐标变长 body,接入 `M2Client.request_dungeon()` /
> `dungeon_event`;实体、回环和 Godot 桥接测试覆盖。缺失 header 清单由 7 项降为 5 项。
> - **2026-08-31 增量 18(领地与观战者)**:补齐 `GC_LAND_LIST`(0x0B12) 的 4B 头 + 24B
> 元素变长解析,以及 `GC_OBSERVER_ADD/REMOVE/MOVE`(0x0B200x0B22) 的 12B / 8B wire
> 结构;`EntityStore` 保存领地和观战者状态,`M2Client.get_land_areas()` /
> `get_observers()` 与 `land_areas_changed` / `observer_event` 已接入;小地图现在消费观战者
> 状态并绘制紫色菱形标记。实体、回环、Godot 桥接和 `p9_test.gd` 覆盖。缺失 header 清单由
> 5 项降为 **1 项**(仅剩 `CG_TEXT`,参考端未发现实际发送方)。
> - **2026-08-31 增量 19(钓鱼动作消费)**`NetWorld` 和 `NetPlay` 现在消费
> `fishing_event` 的 START / STOP / REACT / SUCCESS / FAIL 子头,记录方向并尝试驱动远端及
> 本地主角的钓鱼动作;FISH 子头按参考客户端语义保留为鱼获物品事件,不误当作角色 VID。
> `combat_fx_test.gd`、`netbridge_test.gd` 覆盖动作分发。
> - **2026-08-31 增量 20(钓鱼结果日志)**`ChatUI` 消费 `GC_FISHING` 的 SUCCESS / FAIL /
> FISH 事件,将命中、失败和鱼获物品写入系统 / 战斗日志;`chat_test.gd` 覆盖结果路由。
> - **2026-08-31 增量 21(小地图领地标记)**:小地图继续消费 `GC_LAND_LIST`,把全局厘米坐标
> 的领地矩形绘制为公会金色 / 中性青色边框;`p9_test.gd` 补充领地数据供 headless 重绘路径
> 自检。观战者紫色菱形与领地边框均已接入,仍缺原版 minimap 贴图和 Atlas 交互。
> - **2026-08-31 增量 22(小地图缩放)**`minimap.gd` 新增 `set_scale()` / `get_scale()`
> 对齐原 `CPythonMiniMap::SetScale` 的像素 / 米缩放语义;鼠标滚轮在小地图区域内调整缩放,
> `p9_test.gd` 验证缩放会改变世界坐标投影。仍缺原版贴图、Atlas 拖动和坐标查询窗口。
> - **2026-08-31 增量 23(飞行目标消费)**`NetWorld` 为每个 shooter 保存
> `FLY_TARGETING` / `ADD_FLY_TARGETING` 目标队列;`GC_CREATE_FLY` 在终点 VID 缺失时回退到
> 队列中的目标 VID 或全局厘米坐标,补上原版 `SetFlyTarget` / `AddFlyTarget` 的关键语义。
> `netbridge_test.gd` 覆盖无终点 VID 的投射物创建;真实弹道模型、命中时机和特效仍待补齐。
> - **2026-08-31 增量 24(弓技能发射时机)**`SkillTable.is_ranged()` 根据
> `weapon_limit=BOW` 识别远程技能;`GameScene` 为弓技能暂存 `CG_SHOOT`,在 `.msa` 的 FLY
> motion event 到达时发送,超时自动清理,避免近战技能误发。`skill_test.gd` 与
> `eterngrn_polish_test.gd` 覆盖远近程识别及一次性发射。
> - **2026-08-31 增量 25Atlas 大地图窗口)**:新增 `AtlasUI`,复用地图区块
> `minimap.dds` 构建 Atlas 底图;支持 M 键打开 / 关闭、地图名称、玩家位置(全局厘米)显示、
> 左键拖动和右键复位。无底图资源时仍保留可交互坐标查询;`atlas_test.gd` 与
> `gamescene_test.gd` 覆盖窗口装配和输入路径。
> - **2026-08-31 增量 26(队伍地图标记)**:圆形小地图和 Atlas 从 `get_party()` / 实体快照
> 区分队员,绘制绿色队员点、队长环和 Atlas 队员标记;`p9_test.gd` / `atlas_test.gd` 覆盖
> 队伍标记绘制路径。队伍世界标记已不再是纯 UI 列表,仍需按原版颜色和离线状态逐项校准。
> - **2026-08-31 增量 27(输入差距复核)**:根据 `game_camera.gd` 当前实现修正文档:桌面右键
> 拖拽环绕、滚轮缩放和触屏单指 / 双指手势已经存在,不再列为缺失功能;剩余差距改为相机
> 模式、灵敏度 / 键位持久化及窗口冲突规则。
> - **2026-08-31 增量 28(移动速度校准)**`GC_CHANGE_SPEED` 已暴露实体的
> `moving_speed``NetPlay` 在主角 `entity_info` 到达时调用 `PlayerController.set_server_speed()`
> 以原版 100 为基准缩放本地步行 / 奔跑预测(0.253.0 限幅),`netplay_test.gd` 覆盖 150 → 1.5×。
> - **2026-08-31 增量 29(攻击速度校准)**`GC_CHARACTER_ADD[2]` / `GC_CHARACTER_UPDATE`
> 的 `attack_speed` 已进入 `EntityStore` / `M2Client.entity_dict()`;本地主角的
> `POINT_ATT_SPEED` 也通过 `get_points()` 暴露。`NetPlay` 按 m2dev 的 `attack_speed / 100.0`
> 动作速率换算自动攻击间隔(0.25–3.0 倍限幅,100 → 0.6 秒),并由 `netplay_test.gd` /
> `net_entity_test` 覆盖实体字段和 150 → 0.4 秒。仍需用真实 `.msa` motion duration、武器 / 坐骑
> 规则和服务端攻击合法性逐帧校准。
> - **2026-08-31 增量 4(真机验证)**`net_e2e` 加 post-2026-08-30 协议探针(env 门禁
> `MT_E2E_CHARCREATE` / `MT_E2E_MYSHOP` / `MT_E2E_CUBE`)。真服 `192.168.21.203`
> 确认 1:1`CG_CHARACTER_CREATE`(77B/u16 job) + `GC_PLAYER_CREATE_FAILURE`(5B) +
> `GC_EMPIRE`(5B) + `GUILD_GC_SKILL_INFO`(17B body) + `GUILD_GC_WAR` + `CG_MYSHOP`
> (38B+13B×N) 全部被真服接受 / 解析正确,无 unknown header。Cube/MALL 送出路径通但
> 需 NPC 交互;`CG_CHARACTER_DELETE` / 龙魂精炼 / 会徽上传(破坏性)仍未真机验。
> - **2026-08-31 增量 3**:两个 ⬜ 项落地首版 —— **私人商店 / 道具商城**
> `CG_MYSHOP` / `GC_MALL_OPEN|SET|DEL` / `CG_MALL_CHECKOUT` + `private_shop_ui` /
> `mall_ui`)、**Cube 제작**`GC_CHAT/COMMAND` 的 `cube …` 总线 → `CubeState` +
> `cube_ui`)。仍缺能量条、原版拖放 UI 细节。
> **拍卖行**经核查 m2dev-client 无此系统 → 标注 ❌ 不做(见上「不做清单」)。
> **反作弊**`CG_HACK` + `ProcessCRC`/`ProcessScanner`)→ 标注 ❌ 不做。
> **「协议覆盖审计」段**改为逐条清单:脚本核对 `wire.h` vs `Packet.h`,主网络层
> 仍缺 **24 个 header**(位置同步上行 / 战斗广播 / 任务 / 社交 / 改名 / 世界玩法整组;
> `CG_HACK` 已移入「不做」),另有 3 个「已声明未消费 / 待核」。
> - **2026-08-31 增量 2**
> - 角色创建 / 删除流程完成首版并接近 1:1(原为“暂不纳入”)——见新表行
> 「选人 / 建号 / 删号」。
> - 怪物 / NPC 真模型渲染修复:`root/npclist.txt`vnum→代号→目录)解析 +
> `net_world.catch_up()` 补拉协程化 `setup()` 漏掉的初始 spawn 洪流。世界玩法行
> 的“NPC / 怪物首版”由“能出占位胶囊”提升为“能出真模型 + 贴图 + 动作 + 名字牌 /
> 血条”。
> - `GC_EMPIRE`(0x0109) 已暴露为 `M2Client.get_empire()`;选人页显示国家名 + 国旗。
> - 关键 1:1 差距表新增“状态”列。
## 结论
`mtgodot-poc` 已经不是早期“只能登录、移动和攻击”的骨架:登录到 PHASE_GAME、基础战斗、
背包 / 装备、技能 UI、任务、聊天、组队 / 好友、商店 / 交易 / 仓库、公会、精炼、龙魂、
地图和部分特效都已有可运行首版。
当前的主要差距已从“有没有功能”转为:
1. 部分关键协议只实现了表现包或收包,没有完成原客户端的完整上行语义;
2. 数据模型已扩到 24 个 wear 位置和独立 16 格腰带,并接入部分装备 / 时装换模,但原版
costume / belt 窗口、拖放、tooltip 和部位规则仍不完整;
3. 多数玩法窗口是简化实现,未复现原 Python UI 的完整行为;
4. EffectLib、SpeedTree、材质和世界表现仍有明确占位或近似;
5. 打包资产路径、桌面配置和输入体验尚未形成原客户端等价闭环。
因此当前应称为“核心功能纵切完成”,不能称为 `m2dev-client` 的 1:1 客户端。
## 已具备的首版能力
- 登录、服务器 / 频道选择、频道负载状态、断线重连、选人、**建号 / 删号**、进入游戏;
- Metin2 风选人页(背景 / 3D 角色 / 职业书法字 / 国家 + 国旗 / 四维条 / ◀▶ 槽位 / 建删弹窗);
- 基础移动、攻击、选目标、生命 / 法力 / 经验、伤害飘字、affect、死亡与复活窗口;
- 背包、基础装备换模、地面物品、技能窗口和快捷栏读取;
- 聊天、私聊、基础任务对话和任务日志;
- 组队、好友、情侣名 / 爱意值状态、NPC 商店、交易、仓库;
- 公会、公会技能、公会战、会徽上 / 下载、精炼、龙魂精炼;
- 地形、建筑、水体、**NPC / 怪物真模型(vnum→npclist.txt→目录)**、小地图、频道、昼夜和天气首版;
- GR2 模型 / 动画、换装首版、武器 / 盾挂点、距离 LOD / 淡入、角色接触阴影、
`item_proto` 驱动的球面高光和 `.mse` 粒子首版。
这些项目只说明已有入口和基础闭环;是否 1:1 仍以下表为准。
## 关键 1:1 差距
| 优先级 | 状态 | 模块 | 当前实现 | 需要迁移的原客户端语义 |
|---|---|---|---|---|
| P1 | 🟡 | 选人 / 建号 / 删号 / 改名 | `CG_CHARACTER_CREATE`(0x0201) / `CG_CHARACTER_DELETE`(0x0202) / `CG_CHANGE_NAME`(0x010B) / `GC_PLAYER_CREATE_SUCCESS`(0x020C) / `GC_PLAYER_CREATE_FAILURE`(0x020D) / `GC_PLAYER_DELETE_SUCCESS`(0x020E) / `GC_PLAYER_DELETE_WRONG_SOCIAL_ID`(0x020F) / `GC_CHANGE_NAME`(0x010C) 全部有尺寸断言;`GameClient` 发送接口、角色槽位补丁和 `drain_*_events()``M2Client` 同名建删改 API、4 个建删结果信号及 `char_name_changed``net.loopback_flow` 覆盖建删和改名名称字节 / pid 广播。**真机验证(2026-08-31`net_e2e MT_E2E_CHARCREATE=1`**:建号、`GC_PLAYER_CREATE_FAILURE``GC_EMPIRE` 已验。选人页有建号 / 删号弹窗与国家国旗 | `CG_CHARACTER_DELETE` 破坏性,真机未验;改名 UI / 改名卡消耗和重复名错误提示未接;建号缺外形 / 发型选择与属性再分配 UI(现发 `shape=0` + 职业基础四维);缺 `SELECT_EMPIRE` 选国界面;原版选人台座、镜头动画、`OnCreateFailure` 完整错误码文案 |
| P0 | 🟡 | 技能施放协议 | `CG_USE_SKILL``CG_FLY_TARGETING``CG_ADD_FLY_TARGETING``CG_SHOOT` 已有尺寸断言、原生发送接口和 loopback 字节验证;服务端 `GC_FLY_TARGETING` / `GC_ADD_FLY_TARGETING` 已进入 `EntityStore`,通过 `M2Client.fly_targeting` 暴露,且已补入 `GameClient` 世界分派;`GC_CHANGE_SKILL_GROUP` 会更新 `get_skill_group()` 并使技能快照失效;快捷栏先发 `use_skill(skill_id,target_vid)`,再同步 `CG_MOVE` 技能动作;弓技能会在 `.msa` FLY 事件发送 `CG_SHOOT`。真服单会话已发送 `CG_USE_SKILL` / `CG_FLY_TARGETING` / `CG_ADD_FLY_TARGETING` / `CG_SHOOT`,均无断连 / unknown | 仍需实现范围 / 多目标选择器来自动驱动 `ADD_FLY_TARGETING`;冷却、目标合法性、真实弹道和客户端表现也须按技能类型细分;尚未在真服观察到对应 GC 飞行目标推送 |
| P0 | 🟡 | 快捷栏持久化 | 已覆盖 36 槽(4 页 × 9 格)、F1–F4 切页和 1–9 激活;拖放 / 清除 / 交换会分别发送 `CG_QUICKSLOT_ADD/DEL/SWAP`,服务器 `GC_*` 可完整恢复;技能冷却读取对应 `.msk``CoolTimeFormula` | 仍需补命令 / 表情快捷槽、物品拖放来源、鼠标物品态及原 UI 的完整快捷栏交互;冷却还未叠加原客户端的施法速度与所有特殊技能规则 |
| P0 | 🟡 | 物品操作 | 已有移动、使用、丢弃、拾取;Shift 选“来源→目标”发 `ITEM_USE_TO_ITEM`Ctrl+右键向当前目标 `ITEM_GIVE`Alt+右键以数量框发 `ITEM_DROP2``ITEM_OWNERSHIP` 会实时更新地面名条;`GC_VIEW_EQUIP` 已保存 11 格检查快照并弹出装备查看窗 | 仍需复刻原鼠标物品态、操作确认 / 错误提示、宝石专用规则,以及装备查看窗的原版槽位图标、3D 纸娃娃与交互 |
| P0 | 🟡 | 装备 / 背包数据模型 | 常规背包维持 90 格;运行时模型已扩展到 24 wear 位置,并将腰带改为独立 16 格;背包右侧动态呈现能力 / 时装 / 戒指 / 腰带和 4×4 腰带格,发包使用原客户端 `INVENTORY` 全局 cell`90 + wear` / `152 + belt`),按 `values[0]` 的原腰带等级规则锁格。`EquipModel` 已把本地主角 `parts[]`(时装优先)和装备槽接到身体 shape / TargetSkin、头盔 / 假发 / 发型、右手武器、左手盾牌,并用 `item_proto.specular/100` 驱动护甲球面高光 | 仍需复刻原 costume / belt 窗口的槽位贴图、分页、tooltip 和鼠标物品态;补齐饰品 / 翅膀等多部件遮挡规则、远端角色同等换装以及逐部位高光 / 卸装复位,不以当前兼容性扩展面板代替原 UI |
| P0 | 🟡 | 移动与战斗同步 | 有 `GC_MOVE`、本地预测、攻击;`M2Client.character_position()` 可发送姿态回报,`M2Client.sync_positions()` 可按原包批量发送最多 16 个坐标,`M2Client.request_warp()` 可发送 `CG_WARP` 主动传送请求;`GC_SYNC_POSITION` 批量坐标纠偏、`GC_CHANGE_SPEED``GC_WALK_MODE``GC_CHARACTER_POSITION``GC_DIG_MOTION` 已恢复到实体状态,`NetPlay``moving_speed/100` 校准主角步行 / 奔跑预测,挖矿广播可驱动目标朝向 / dig 动画,`WALK_MODE` 会驱动远端 walk/run 动画选择;真服单会话已发送 `CG_CHARACTER_POSITION`(5B) 与 `CG_SYNC_POSITION`(16B),均无断连 / unknown | 仍需按原客户端同步窗口、碰撞纠偏触发时机与保留动作处理,而不是只靠距离阈值和 lerp;姿态动作还没有完整 UI / 动画语义 |
| P0 | 🟡 | PVP / 决斗 / 阵营 | `GC_PVP``DUEL_START` 已进入状态层,并以 `pvp_changed` / `duel_started` 信号提供给 Godot;挑战 / 战斗 / 复仇关系会在世界实体上显示标签;实体已有部分 empire / alignment / pk 字段 | 仍需 PK 模式上行、原版名字颜色、可攻击判定、阵营规则和原版目标板 / 决斗 UI |
| P1 | 🟡 | Actor 战斗运行时 | 有骨骼动画、基础 combo / 硬直 / 相机抖动;动作切换按 Granny `EaseInCurve(0,0,1,1)``2w²−w³` 逐骨 crossfade;角色 mesh 支持距离 LOD、15% 迟滞和约 0.18s 新旧 mesh 淡入(`LodGhost` + `lod_fade` uniform);右手武器 / 左手盾按 `weaponComposite[0] = weaponInvWorld[0]·weaponLocal[0]` grip 复合挂 `equip_right_hand`(探针实测 Δ≤0.06 m,非标准米级偏移文件夹断回退);`.msa` motion event 已分派声音、EffectPosition 特效、震屏和屏闪(FLY / WARP 留钩子);玩家 / 怪物强制实时投影并附脚底接触阴影 Decal;实体 `attack_speed` 与本地 `POINT_ATT_SPEED` 已按原版 100 基准影响攻击请求间隔 | 仍需用真实 `.msa` motion duration、武器 / 坐骑规则和服务端合法性校准攻击节奏;另补 combo 取消窗口、精确受击 / 击退、角色碰撞、同步碰撞、武器拖尾、剩余 motion event / 骨骼挂点语义、GPU skin LOD、双持 / 左手武器和目标锁定相机;`03150` 类偏移网格需 per-weapon 偏移数据或修 .gr2 |
| P1 | 🟡 | 飞行物与命中特效 | `GC_FLY_TARGETING` / `GC_ADD_FLY_TARGETING` 保存 shooter 的目标队列和 VID / 坐标;`GC_CREATE_FLY` 可消费队列并生成 tween 发光球 | 对齐 `FlyingObjectManager`:真实技能弹道模型、命中时机 / 命中特效、消失条件、声音与 EffectLib 特效 |
| P1 | 🟡 | 任务 / NPC | 支持 `[ENTER]``[CLEAR]``[NEXT]``[DONE]`、基础 `[QUESTION]``[INPUT]` 文本框和任务日志;`M2Client.script_select_item()` / `quest_cancel()` 已可发送原协议应答,Escape 会走取消包;真服单会话已发送 `CG_SCRIPT_SELECT_ITEM`(8B) 与 `CG_QUEST_CANCEL`(4B),取消触发 1 条 quest-change 且无断连 / unknown | 补选道具网格、原版 CJK IME / 输入校验、QuestButton、立绘、完整 EventManager 标签、世界箭头和屏幕边缘指示;选道具包本轮未出现脚本业务回包 |
| P1 | 🟡 | 商店 / 交易 / 仓库 | 普通 NPC 商店、交易状态和仓库列表已有首版;**`SHOP_GC_START_EX` 多货架已解析(`m_shop_tabs``get_shop()`),`shop_ui` 带货架按钮 + `tabIdx*40+slot` 买位置** | 补图标格网格、数量输入、交易回滚提示、仓库密码 / 改密码、完整拖放规则、`coin_type` 非金币货币显示 |
| P1 | 🟡 | 私人商店 / 道具商城 | **首版**`CG_MYSHOP`(0x0802) `CGMyShopHead`(38) + `MyShopItem`(13)×N 有尺寸断言 + `GameClient::send_open_private_shop` / `send_close_private_shop`(=`SHOP_CG_END`) + loopback 字节级;`GC_MALL_OPEN`(0x0841)/`GC_MALL_SET`(0x0842)/`GC_MALL_DEL`(0x0843) 路由进 `EntityStore``m_mall[135]` + `mall_open/size/slot/dirty`),`CG_MALL_CHECKOUT`(0x0840) `CGMallCheckout`(8) 发送;`M2Client` `open/close_private_shop``get_mall_items`/`mall_checkout` + `mall_opened`/`mall_changed` 信号;`ui/private_shop_ui.gd`**已重做成 `PrivateShopBuilder.itemStock` 模型:40 格 grid + 拿起 / 落位 / 逐件价格弹窗 / 点占用格撤下 / 개설按格号排序 + display_pos + 上限 39**)、`ui/mall_ui.gd`(列表 + 取出)。**真机验证(2026-08-31`net_e2e MT_E2E_MYSHOP=1`**`CG_MYSHOP` 38B head + 13B item×1 被真服接受,无断连 / 无 shop error`SHOP_CG_END` 关店正常。`CG_MALL_CHECKOUT` 送出不断连,但 mall 需点 NPC 才 open。他人开店 `SHOP_GC_START_EX` 多货架已解析(见增量 34)。⬜ `SYMBOL_DATA`/`GC_SHOP_SIGN` 头顶招牌 3D、真 `.sub``privateshopbuilder.py` uiscript、鼠标真拖放物品态 |
| — | ❌ | 拍卖行 / auction house | 不做 | m2dev-client 无此系统(`0x08xx` 段只有 shop/myshop/safebox/mall,全源码 / 协议 / 资产零 `auction` 命中)。需自定义协议 + 服务端支持才能实现,**标注为不做**,除非后续明确提需求 |
| P1 | 🟡 | Cube (제작) / 能量系统 | **首版**Cube 走 `GC_CHAT`/`CHAT_TYPE_COMMAND` 文本总线(对齐 `ServerCommand()`),`EntityStore::apply_server_command` 解析 `cube open/close/info/success/fail/r_list/m_info`(配方 `v,c` 列表 + `@`/`&`/`|`/`/` 分层材料 + 金币)→ `CubeState` + `drain_cube_events()``GameClient::send_cube_make/material_info/result_list``/cube make|mInfo|rList`);`M2Client` `get_cube`/`cube_make`/`cube_request_*` + `cube_opened`/`cube_closed`/`cube_changed`/`cube_result` 信号;`ui/cube_ui.gd`(配方列表 + 材料/金币 + 제작)。`net.entity_store` + `net.loopback_flow` 覆盖。真机(2026-08-31`/cube rList` 送出不断连,但服务器只在玩家站到 cube NPC 前才回应 → `cube open=0`,需 NPC 交互完整验。⬜ 能量条(`POINT_ENERGY` / affect)、原 `uiCube.py` 材料格 3D 图标 / 拖放 / 결과 애니、NPC 前完整验 send 命令字 |
| P1 | 🟡 | 组队 | 邀请、接受、离队;**成员信息板(`party_ui.gd`1:1 迁移 `uiparty.PartyMemberInfoBoard`):每员角色状态按钮(队长弹菜单 → `CG_PARTY_SET_STATE` 0x0704 分配 攻/坦/狂/辅/宗/防 或踢人)+ 名字(★队长) + HP gauge + `affects[7]` 附加效果 chip;顶部 EXP 分配开关(`party_set_distribute`+ 组队治疗(`party_use_skill(1,0)`**;真服单会话 `CG_PARTY_USE_SKILL`(9B) 后仍在线;小地图 / Atlas 绿色队员点 + 队长环 | 补角色菜单按党技能等级门控(Tanker≥10…Defender≥40)、`affects[7]` 精确槽位语义、真版 `.sub` 图标 / 颜色、队伍技能冷却 / 权限、离线颜色语义 |
| P1 | 🟡 | 好友 / 情侣 | 好友列表、上线状态和私聊入口已有;`GC_LOVER_INFO`(70B) / `GC_LOVE_POINT_UPDATE`(5B) 已有尺寸断言、`EntityStore::LoverInfo` 状态、`M2Client.get_lover()` / `lover_changed``love_ui` 常驻显示伴侣名和 0–100 爱意值;`net.entity_store``love_ui_test.gd` 覆盖 | 补好友分组和完整 messenger 行为;情侣仍需完整交互 / 状态图标与 affect 表现、断线 / 切图生命周期、原版文案和真服务器端到端验收 |
| P1 | 🟡 | 公会 | 信息、成员、技能、公会战、会徽上 / 下载和部分操作已有首版;`GC_REQUEST_MAKE_GUILD`(0x0731) 已解析并以 `M2Client.guild_make_requested` 通知;`CG_SYMBOL_CRC` / `GC_SYMBOL_DATA` 已支持原始符号文件下载 | 补完整权限 / 等级页、公告与日志、成员管理、创建公会 UI / 应答流程、公会战应答、积分板、领地和公会建筑 |
| P1 | 🟡 | 世界玩法 | 有 `CG_WARP` 主动请求、`GC_WARP` 同服 / 跨服处理、`CG_FISHING` / `GC_FISHING` 钓鱼方向与状态事件、`NetWorld` / `NetPlay` 钓鱼动作分发、`ChatUI` 鱼获 / 失败日志、`CG_DUNGEON` / `GC_DUNGEON` 副本请求与状态事件、`GC_DIG_MOTION` 挖矿动作广播、`GC_LAND_LIST` 领地状态、`GC_OBSERVER_ADD/REMOVE/MOVE` 观战者状态、频道、时间、NPC marker、天气粒子和 mount_vnum 字段;真服登录洪流已收到并解析 `GC_LAND_LIST`(436B),单会话发送 `CG_FISHING`(5B) 后收到“Please choose a Fishing Pole.”业务拒绝且连接保持;同服 `GC_WARP` 直接挪玩家,跨服 `addr≠0``M2Client` 复用 login key 直连目标 game server,断点重连也走该快路径 | 补完整钓鱼 UI / 鱼获背包落地、副本计时 / 入口 UI、坐骑真模型 / 骑乘动作 / 移速、地形贴花、DungeonBlock 和地图天气配置;`CG_DUNGEON` / `CG_WARP` 本轮为避免副作用未发送,跨服换图仍需真服端到端验收与场景生命周期清理 |
| P1 | 🟡 | 小地图 / Atlas | 可画玩家、实体、NPC、任务标记、观战者紫色菱形和 `GC_LAND_LIST` 领地边框;支持 `set_scale()` / 鼠标滚轮缩放;Atlas 支持底图、地图名称、玩家坐标、拖动和坐标查询 | 补原 minimap / Atlas 贴图细节、提示、队员 / NPC 分类、世界箭头、地图驱动天气配置和原版窗口视觉 |
| P1 | 🟡 | EffectLib | `.mse` Particle 已映射到 `GPUParticles3D` | 粒子 `.dds` 目前被程序化径向渐变替代,`.mde` 用 box 占位;需补 EffectMesh、SimpleLight、TexAni、MovingType、骨骼挂点和完整时间轴 |
| P1 | 🟡 | SpeedTree | `.spt` 只嗅探真实 bark / composite 贴图,几何为程序化 proxy | 迁移或离线转换真实树干、叶片、frond、LOD、billboard、风和阴影;当前不能作为 1:1 树木 |
| P1 | 🟡 | 地图 / 材质渲染 | A1 地形、静态物、水和 `.msenv` 环境已有首版;splat 已支持每区块最多 16 个活动层、原客户端 UV / offset 与最大源分辨率,地形拆 patch 做视锥 / 距离剔除;水使用原 30 帧 DDS 序列、水深 alpha 和轻微高度动画;静态物 Y-up 旋转共轭已修正。GR2 blend / two-sided 优先读材质信息,球面高光管线已接 `item_proto.specular`;角色有实时投影 + 接触阴影 | 补其余固定功能材质与多贴图分支、精确 alpha-test / blend / cull、透明排序、逐 patch 几何 LOD、baked / realtime 阴影 mask、血迹 / 施法阵等 Decal、云层和 lens flare;按受控参考帧校准水深、LOD、阴影、光照和材质 |
| P1 | 🟡 | UI 控件语义 | 80 个 `uiscript` 可解析和构建,覆盖约 20 类基础节点 | 解析成功不等于窗口完成;需补动画图片、slot 拖放、scrollbar/listbox 行为、tooltip、radio group、候选窗、原层级 / 锚点 / 焦点 / modal 规则 |
| P1 | 🟡 | UI 窗口行为 | 背包、技能、任务、聊天、社交和商店等有简化窗口;腰带背包、扩展装备和装备查看已有兼容窗口;**选人页已按 Metin2 风重做**;**角色状态窗(`char_status_ui.gd`)已按 `characterwindow.py` 真布局 + `uicharacter.py.RefreshStatus` 逐字绑定 + 原 `/stat` 加点命令做成,作为 1:1 窗口迁移样板** | **选魔石窗** `select_item_ui.gd`、**系统设置窗** `system_option_ui.gd`、**游戏设置窗** `game_option_ui.gd``/setblockmode` + `/pkmode` 真命令 + 显示开关持久化)、**ESC 系统菜单** `system_menu_ui.gd``systemdialog.py` + `/phase_select`/`/logout`/`/in_game_mall`)、**私人商店窗** `private_shop_ui.gd`、**组队成员板** `party_ui.gd` 均已按样板做成 | 按样板逐个复现原 `Interface.MakeInterface()` 装配与视觉 / 交互细节:帮助窗、Atlas、商城 / Cube 真 uiscript、Guild Building、game_option 的名字颜色 / 伤害数字等渲染侧钩子(拍卖行参考端无此窗口)|
| P2 | 🟡 | 输入 / 快捷键 | WASD、点地、触屏、右键拖拽环绕、滚轮缩放、双指捏合、I/K/J/O/G/L/Z、F1F4 快捷栏切页和数字键 1–9 激活已接;实体点选从固定半径提升为优先使用子 mesh 合并世界 AABB,无码型实体才回退胶囊近似 | 对齐空格连续攻击、显示名字、鼠标物品态、PK 模式、正式客户端截图、相机模式与灵敏度配置持久化、键位配置和窗口冲突规则 |
| P2 | 🟡 | CJK 输入与本地化 | 支持 KEY→VALUE 查表和基础字体注入 | 补 `PythonIME` 中 / 日 / 韩候选窗、复数 / 性别规则、字体自动切换、完整 item / mob 名称与 tooltip 本地化 |
| P2 | 🟡 | 音频 | 有 BGM crossfade、UI 音效池和 3D 音效 | 补地图 BGM 自动选择、`.msenv` 环境循环音、武器 / 技能 / 怪物 / NPC 声音事件和原距离衰减规则 |
| P2 | 🟡 | 客户端外壳 | Godot 工程已有 macOS / iOS / Android 构建脚本 | 补 `config.exe` 对应的分辨率、显示、音量、阴影、语言和鼠标配置;Movie / Web / Logo、补丁器约束、Discord RPC 按发布目标取舍 |
| P0 发布 | 🟡 | Pack 资产统一读取 | `PackMount` / `AssetSource` 已实现并有 roundtrip 测试 | `Metin2World` / `Metin2Model` 等仍有直接 `AssetResolver::resolve()` 的散文件路径;必须统一切到 `AssetSource::read/to_path()`,否则 `.epk/.eix` 打包客户端不等价 |
## 参考模块覆盖索引
下表以 `m2dev-client-src-main/src/UserInterface/` 的模块职责为索引,补充上表未展开的基础能力。
“已有”只表示当前有可运行首版;仍需满足本文末的 1:1 完成判据。
| 参考模块 / 能力域 | 当前实现 | 与 m2dev-client 的差距 | 优先级 |
|---|---|---|---|
| `AccountConnector` / `PythonNetworkStreamPhase*` 登录与相位 | auth → game → 选人 → LOADING → PHASE_GAME 已打通;`LoadingScreen``ReconnectUI`、跨服 `GC_WARP` 重连和频道状态探针均有首版 | 登录 / 选服 / 选频道的原版错误码、表单校验、超时与会话失效文案,服务器列表刷新和所有相位边界清理仍未逐项对齐 | P1 |
| `PythonChat` / `PythonTextTail` / `PythonPlayer` | 聊天四标签、私聊 / 组队 / 公会 / 喊话路由,实体气泡、名字、HP 条、伤害飘字和本地气泡已有 | 缺称号、帮会名、名字颜色和目标高亮框;`GC_DAMAGE_INFO`、经验 / 金钱变化的原版日志行、notice / big-notice 排版、历史滚动和 `InsultChecker` 过滤未完整迁移 | P1 |
| `PythonPlayer` HUD / affect / target | HP、SP、经验、等级、目标血条、死亡复活窗和 affect 色块条已绑定 | 原版状态栏布局、图标贴图、持续时间倒计时、目标板按钮 / 交互、所有 point 类型和战斗日志格式仍是简化版 | P1 |
| `PythonPlayerInput*` / `CameraProcedure` / `PythonApplicationCursor` | WASD、点地、触屏点选 / 单指相机拖动 / 双指捏合、桌面右键拖拽与滚轮缩放、网格 AABB 点选、相机遮挡淡出和碰撞已有;`GC_CHANGE_SPEED` 会校准本地移动速度 | 缺原版光标状态(talk / attack / buy / sell / pick 等)、相机模式与灵敏度配置持久化、键位配置、窗口冲突和空格连续攻击语义 | P1 |
| `EterPythonLib` / `PythonApplicationModule` UI 运行时 | 80 个真实 `uiscript` 可解析;约 20 类控件、窗口栈、modal、拖动标题栏和通用弹窗已有 | `ani_image``mark`、真实 `grid_table` / scrollbar / list 行为、`.dds` UI 贴图、鼠标物品态、焦点 / 锚点 / 层级和原版九宫格视觉仍未完全对齐 | P1 |
| `PythonEffect` / `PythonFly` / `EffectLib` | `.mse` Particle → `GPUParticles3D`、技能 / 服务器特效触发、基础飞行球和动作事件已有 | `.dds` 粒子纹理、`.mde` mesh、SimpleLight、MovingType / TexAni 时间轴、骨骼挂点、真实弹道 / 命中事件和声音仍缺 | P1 |
| `PythonMiniMap` / `PythonBackground` | 圆形小地图、实体 / NPC / 任务点、观战者、领地边框、队员 / 队长标记、鼠标滚轮缩放;Atlas 已有区块底图、拖动、坐标查询、地图名称和玩家 / 队员标记;地形 / 建筑 / 水体 / 天气和昼夜首版已有 | 缺原 minimap / Atlas 贴图细节、世界箭头、地图驱动天气配置和原版窗口视觉 | P1 |
| `PythonSystem` / `MovieMan` / `PythonApplicationWebPage` / `Discord` | Godot macOS / iOS / Android 构建脚本、基础窗口和音频设置入口已有 | 缺 `config.exe` 的分辨率 / 阴影 / 语言 / 鼠标配置持久化、Movie / Logo / Web 页面、补丁器约束及按发布目标取舍的 Discord RPC | P2 |
| `ProcessCRC` / `ProcessScanner` / `PythonProfiler` | 反作弊明确列为不做 | 若服务端未来强制校验,只补最小协议上报;性能采样、崩溃上报和诊断工具不属于当前 1:1 交付范围 | — |
## 协议覆盖审计
`m2dev-client-src-main/src/UserInterface/Packet.h` 为基准(`namespace CG` 68 个 +
`namespace GC` 109 个 = **177** 个协议键)。增量 2/3 已补 `CG_CHARACTER_CREATE` /
`CG_CHARACTER_DELETE` / `GC_PLAYER_CREATE_SUCCESS` / `GC_PLAYER_CREATE_FAILURE` /
`GC_PLAYER_DELETE_SUCCESS` / `GC_PLAYER_DELETE_WRONG_SOCIAL_ID` / `CG_MYSHOP` /
`CG_MALL_CHECKOUT` / `GC_MALL_OPEN` / `GC_MALL_SET` / `GC_MALL_DEL` 共 11 个,并把已声明的
`GC_EMPIRE` 接到 `M2Client.get_empire()`。增量 5 又把此前仅声明的 `GC_LOVER_INFO` /
`GC_LOVE_POINT_UPDATE` 接入状态、信号和 UI;增量 6 又补了
`CG_CHARACTER_POSITION` / `CG_SYNC_POSITION` 的上行构造和 API。这两项不再计入缺失清单,
故当前总数从 24 降为 22 个 header;增量 7 再补任务选择 / 取消上行,增量 8 补队伍技能上行,
增量 9 补飞行目标广播,增量 10 补技能组切换,增量 11 补角色改名,增量 12 补公会创建请求,
增量 13 补主动传送,增量 14 补挖矿动作,增量 15 补会徽符号下载,增量 16 补钓鱼协议,
增量 17 补副本协议,增量 18 补领地与观战者,当前为 **1 个 header**
排除仅命名不同、功能已覆盖的别名(`GC_CHAR_ADDITIONAL_INFO`=`GC_CHAR_ADD_INFO`
`GC_TARGET`=`GC_TARGET_INFO``GC_TARGET_CREATE_NEW`=`GC_TARGET_CREATE`
`GC_CHARACTER_UPDATE2`=`GC_CHARACTER_UPDATE``GC_OWNERSHIP`=`GC_ITEM_OWNERSHIP`
`GC_SEPCIAL_EFFECT`=`GC_SPECIAL_EFFECT``GC_REFINE_INFORMATION_NEW`=`GC_REFINE_INFO_NEW`),
以及 `CG_STATE_CHECKER` / `GC_RESPOND_CHANNELSTATUS``project/net/channel_status.gd` 单独实现)
`CG_HACK`(反作弊,见「不做清单」),
**当前主网络层仍缺 1 个 header**(脚本核对 `wire.h` vs `Packet.h`):
### 账号 / 命令通道
| header | 值 | 说明 |
|---|---|---|
| `CG_TEXT` | 0x0011 | 简易命令通道(Cube 现借 `CG_CHAT`/`CHAT_TYPE_COMMAND` 实现,非原路径) |
### 已声明但待核(不计入上面 1 个)
- `CG_CHARACTER_POSITION`(0x0A60) / `CG_SYNC_POSITION`(0x0303)wire、发送接口和
loopback 字节测试已覆盖;真服单会话已分别发送 5B / 16B,均保持在线且无 unknown;仍缺原版
自动触发语义、姿态动作链和服务端位置纠偏回包验收。
- `CG_SCRIPT_SELECT_ITEM`(0x0903) / `CG_QUEST_CANCEL`(0x0906)wire、发送接口和
loopback 字节测试已覆盖;真服单会话已分别发送 8B / 4B,取消触发 1 条 quest-change 且无
unknown;基础 `[INPUT]` / Escape 取消已接入,仍缺选道具网格、原版输入校验和有脚本时的任务
流程验收。
- `CG_PARTY_USE_SKILL`(0x0705)wire、发送接口和 loopback 字节测试已覆盖;真服单会话已发送
9B 且保持在线 / 无 unknown,但无队伍技能业务回包;仍缺冷却 / 权限、UI 入口和有效队伍真服
验收。
- `GC_FLY_TARGETING`(0x0411) / `GC_ADD_FLY_TARGETING`(0x0412)wire、EntityStore 状态、
`M2Client.fly_targeting` 信号和 `NetWorld` 目标队列消费已覆盖;本轮发现并修复
`GameClient` 未分派这两个 GC header 的 bug;真服本轮未主动推送,仍缺真实弹道模型、命中时机
和服务端广播验收。
- `GC_CHANGE_SKILL_GROUP`(0x021D)wire、EntityStore 状态、dirty 失效和
`M2Client.skill_group_changed` 已覆盖;本轮发现并修复 `GameClient` 未分派该 header 的 bug
仍缺技能组选择 UI、服务端权限和真服推送验收。
- `CG_CHANGE_NAME`(0x010B) / `GC_CHANGE_NAME`(0x010C)wire、角色槽位更新、
`M2Client.change_name()``char_name_changed` 已覆盖;仍缺改名 UI、改名卡消耗 / 重名错误
文案和真服验收。
- `GC_REQUEST_MAKE_GUILD`(0x0731):无负载 wire 解析和 `guild_make_requested` 信号已覆盖;仍缺
创建公会窗口、名称校验 / 应答和真服验收。
- `CG_SYMBOL_CRC`(0x0723) / `GC_SYMBOL_DATA`(0x0732)16B / 8B wire、`MarkClient` CRC 请求、
变长数据接收和 `M2Client.guild_symbol_ready` 已覆盖;仍缺本地符号缓存失效策略、UI 预览和
真服验收。
- `CG_FISHING`(0x0B01) / `GC_FISHING`(0x0B10)5B / 10B wire、旋转方向换算、
`EntityStore` 事件、`M2Client.fishing_event`、远端 / 本地主角动作分发和 ChatUI 结果日志已
覆盖;真服单会话已发送 5B,收到“Please choose a Fishing Pole.”业务拒绝且无断连 / unknown,
证明包布局和错误路径可达;仍缺持竿后的完整钓鱼 UI、鱼获背包落地和成功流程验收。
- `CG_DUNGEON`(0x0B02) / `GC_DUNGEON`(0x0B11)4B / 5B wire、目的地坐标变长 body、
`EntityStore` 事件和 `M2Client.dungeon_event` 已覆盖;仍缺副本计时 / 入口 UI、地图生命周期
清理和真服验收。
- `GC_LAND_LIST`(0x0B12)4B 固定头 + 24B `LandPacketElement` 变长解析、领地状态、
`M2Client.get_land_areas()` / `land_areas_changed` 和小地图领地矩形边框已覆盖;真服登录洪流
已收到并解析 436B;仍缺领地贴图 / 建筑和有实际领地变更时的推送验收。
- `GC_OBSERVER_ADD/REMOVE/MOVE`(0x0B200x0B22)12B / 8B wire、持久观战者状态、
`M2Client.get_observers()` / `observer_event` 和小地图紫色菱形标记已覆盖;仍缺真服验收。
- `CG_WARP`(0x0305)4B wire、`GameClient.send_warp()``M2Client.request_warp()` 和回环
请求 / `GC_WARP` 回传测试已覆盖;仍缺传送确认 UI、冷却 / 失败提示和真服验收。
- `GC_DIG_MOTION`(0x0308)13B wire、`EntityStore` 事件、`M2Client.dig_motion`
`NetWorld` 朝向 / 动画元数据处理已覆盖;仍缺真实挖矿动作资源、音效 / 特效和真服验收。
- `GC_ITEM_DROP`(0x0513):疑似被 `GC_ITEM_GROUND_ADD`(0x0515) 取代,暂按等价处理,未逐字节核。
- `GC_LOGIN_KEY`(0x0107)auth 握手用;`net_e2e` 真机 auth→PHASE_GAME 全程已打通,功能等价
(可能走 `GC_AUTH_SUCCESS` 0x0108),未单独立项。
“header 已声明”也不能直接视为完成:还需检查封包尺寸、变长 body、所有 sub-header、
`EntityStore` 状态、GDScript 信号、UI 消费和真服务器端到端行为。
## 建议迁移顺序
### M1:服务器语义正确
1. 把远程 `SHOOT` 时机、范围目标与技能类型冷却接入现有技能协议;
2. 快捷栏的命令 / 表情 / 物品拖放语义与特殊冷却规则;
3. 细化 24 wear / 腰带 / 装备查看的原版窗口表现,并完成完整物品操作;
4. 移动同步、PVP / 决斗和 Actor 战斗状态机;
5. 用 loopback + 真服抓包为每类协议建立双向验收。
### M2:主要玩法完整
1. 任务完整标签 / 输入 / 选道具 / 指引;
2. 商店 START_EX、仓库密码、商城、私人商店、Cube(拍卖行 m2dev-client 无,除非另起协议);
3. 组队角色、公会完整页面、情侣完整交互、领地与公会建筑;
4. 钓鱼、副本、坐骑、观战者和 Atlas。
### M3UI 与表现 1:1
1. 以原 `interfacemodule.py` 为窗口清单,逐窗口迁移行为;
2. 完成 tooltip、拖放、光标、IME、快捷键和系统设置;
3. 完成 EffectLib、SpeedTree、材质、地图表现和声音事件;
4. 固定原客户端参考机位、角色、地图、时间和 UI 状态,建立截图 / 录屏签核。
### M4:发布闭环
1. 所有资产调用方统一使用 `AssetSource`,验证纯 pack 运行;
2. 覆盖目标平台构建、前后台、输入和性能;
3. 按服务器要求决定 Discord / Web / Movie 等桌面能力是否迁移(**反作弊 `ProcessCRC` /
`ProcessScanner` 已标注 ❌ 不做**,除非服务端强制校验)。
## 1:1 完成判据
一个功能只有同时满足以下条件才标记为完成:
1. 原客户端涉及的 CG / GC 包和 sub-header 全部覆盖,并有尺寸断言;
2. 数据状态能正确恢复、重连、切图和清理;
3. UI 操作、错误提示、拖放、快捷键和窗口互斥与原客户端一致;
4. 模型、动画、特效、声音和名字 / tooltip 表现一致;
5. loopback、真服务器端到端、受控截图或交互录屏至少有一种自动门禁和一种人工签核。
在达到以上判据前,应继续使用“首版”“部分完成”描述,避免把“能解析布局”“有一个按钮”或
“能播放动作”写成 1:1 完成。
+243
View File
@@ -0,0 +1,243 @@
# 客户端移植 —— 从素材渲染器到能联机的客户端
> 起点:`mtgodot-poc` 的渲染纵切「差不多能用」(见 [`PARITY-GAP.md`](./PARITY-GAP.md))。
> 本文是把它变成**能连服务端玩**的客户端的分层计划。分水岭是**网络层**——别的玩家、
> 物品、战斗、聊天都以封包为数据源。
>
> 玩法 / 客户端系统层面「原始客户端有、这里还没有」的完整差距清单见
> [`CLIENT-GAP.md`](./CLIENT-GAP.md)。
## 0. 目标服务端
- LAN`192.168.21.203`(这台 Mac 能路由到,但握手端口目前 **connection refused** ——
服务端进程没起 / 端口和默认不同,需确认)。
- 默认端口(`assets/root/serverinfo.py`):auth `11000`,频道/游戏 `11011/11021/11031/11041`
- 测试账号:`admin` / `123456789`
## 1. 协议(本 m2dev fork**不是老 Metin2**
### 1.1 帧格式
每个包 = `[uint16 header LE][uint16 length LE][payload...]``length` = 含 4 字节前缀的整包长度
(≥ 4)。包间的 0 字节是加密对齐 padding,跳过。header 是 `CG::`/`GC::` 命名空间常量
`UserInterface/Packet.h` + `EterLib/ControlPackets.h`)。
### 1.2 加密握手(libsodium
`EterBase/SecureCipher` —— X25519 密钥交换(`crypto_kx`+ 每方向固定 nonce 的 XChaCha20
流加密(带运行字节计数,**顺序敏感**)+ `crypto_auth` HMAC 挑战应答 + XChaCha20-Poly1305
AEAD 加密 session token。
握手序列(连上 auth server 后):
| 方向 | 包 | header | 动作 |
|---|---|---|---|
| S→C | `GCPhase{phase=PHASE_HANDSHAKE}` | `GC_PHASE` 0x0008 | 进握手态 |
| S→C | `GCKeyChallenge{server_pk[32], challenge[32], server_time}` | `GC_KEY_CHALLENGE` 0x000B | `Initialize()``compute_client_keys(server_pk)` |
| C→S | `CGKeyResponse{client_pk[32], challenge_response[32]}` | `CG_KEY_RESPONSE` 0x000A | `get_public_key()``compute_challenge_response(challenge)` |
| S→C | `GCKeyComplete{encrypted_token[48], nonce[24]}` | `GC_KEY_COMPLETE` 0x000C | `decrypt_token()`→token`set_activated(true)`;解密已缓冲的剩余字节 |
| S→C | `GCPhase{phase=PHASE_AUTH}` | `GC_PHASE` | 进认证态 |
| C→S | `CGLogin3{name[31], pwd[17]}` | `CG_LOGIN3` 0x0102 | 明文账号密码(**此时流已加密**)|
| S→C | `GCAuthSuccess{login_key, result}` | `GC_AUTH_SUCCESS` 0x0108 / 失败 `GC_LOGIN_FAILURE` 0x0106 | `result` 真→拿 `login_key` |
拿到 `login_key` 后断开 auth,连**游戏服**`:11011`),同样握手,然后 `CGLogin2{name, login_key}`
`CG_LOGIN2` 0x0101)→ 选人 → 加载 → 进游戏相位。
> S→C 流 nonce `[0]=0x01`C→S `[0]=0x02`。`ping`(`GC_PING` 0x0007)→`pong`(`CG_PONG` 0x0006) 全程要回。
## 2. 进度
| 模块 | 状态 | 位置 |
|---|---|---|
| `SecureCipher`KX + 流 + AEAD token | ✅ 端口完成,`net.cipher_roundtrip` CTestKX / 挑战应答 + 篡改拒绝 / token 往返 / 跨块流加解密 / 字节计数) | `extension/src/net/secure_cipher.{h,cpp}` |
| `wire.h`(帧 + 控制 + KX + auth 结构体) | ✅ `#pragma pack(1)` 尺寸断言 | `extension/src/net/wire.h` |
| `ByteBuffer`(收发暂存) | ✅ header-only | `extension/src/net/byte_buffer.h` |
| libsodium 接入 | ✅ 从源码 vendor`extension/third_party/libsodium-cmake` + zstd + miniLZO),macOS/iOS 都编过,不再依赖 Homebrew。详见 `docs/THIRD-PARTY.md` | `extension/third_party/CMakeLists.txt` |
| `NetStream`(非阻塞 POSIX socket + `process()` 循环 + 帧解析 + 内建控制面 handler:`GC_PHASE` / `GC_PING``CG_PONG` / KX 握手 / 激活后解密已缓冲字节) | ✅ 编译通过 | `extension/src/net/net_stream.{h,cpp}` |
| `AuthClient`auth 相位:`PHASE_AUTH`→发 `CGLogin3`→收 `GCAuthSuccess`/`GCLoginFailure`) | ✅ 编译通过,等可达服务端端到端测 | `extension/src/net/auth_client.h` |
| `GameClient`(游戏服:`PHASE_LOGIN`→发 `CGLogin2{name, login_key}`→收 `GCLoginSuccess3` 角色列表→`select_character()``PHASE_SELECT/LOADING/GAME` | ✅ 编译通过。角色列表按 `TSimplePlayerInformation`pack(1)103B)解析成 `{index,id,name,job,level,x,y}`;游戏相位包暂只回 PING、记未知 header | `extension/src/net/game_client.h` |
| `net_probe` 诊断工具 | ✅ `net_probe <host> <port> <id> <pw>`,打印 state / 握手 / 登录结果 / login_key | `extension/tools/net_probe.cpp` |
| **`M2Client` GDExtension 节点** | ✅ 已注册可用。`connect_to_server(...)` 编排 Auth→(`auth_ok`)→GameConnect→游戏相位;`select_character(index)`。信号 `stage_changed` / `phase_changed` / `auth_ok` / `login_failed` / `char_list` / `entered_game` / `disconnected`。生命周期(F5):`suspend()`/`resume()`/`reconnect()` + `suspended`/`resumed` 信号,`NOTIFICATION_APPLICATION_PAUSED/RESUMED` 自动触发。烟测:正确报 `auth_connect → login_failed(Connection refused) → failed` | `extension/src/net/m2_client.{h,cpp}` |
| **`M2Client` 游戏内 API** | ✅ 出站 `move(func,arg,rot_deg,x,y)`rot `/5``time` 取帧钟)/ `attack(motion,vid)` / `set_target(vid)` / `say(type,text)`,未入局返回 false。快照 `get_entities()` / `get_entity(vid)`(含 hp/sp/level/dead/stunned,坐标转 Godot 米)/ `get_main_vid()` / `get_points()`HP/SP/level/exp/gold/ `get_target()`vid + hp%)。`pump_game()` 每帧 `EntityStore::set_now/tick` 并抽信号:`entity_spawned/despawned/moved/main_set``chat``vitals_changed(vid)``entity_dead(vid)`(首次去重)、`damage(vid,amount,flag)``motion(vid,victim,motion)``points_changed(Dict)``target_info(vid,hp%)``net.loopback_flow` 扩展:出站 `CG_MOVE/ATTACK/TARGET/CHAT` 经加密信道字节级校验 | `extension/src/net/m2_client.{h,cpp}` |
| **游戏相位封包结构** | ✅ `wire.h``GC_MAIN_CHARACTER` / `GC_CHARACTER_ADD[2]` / `GC_CHARACTER_DEL` / `GC_CHARACTER_UPDATE` / `GC_MOVE` / `GC_CHAT` + `CG_MOVE` / `CG_ATTACK` / `CG_TARGET` / `CG_CHAT`。**战斗/状态**(对 `Packet.h`):`GC_PLAYER_POINTS`(0x0214, int32[255] 1024B) / `GC_PLAYER_POINT_CHANGE`(0x0215) / `GC_STUN`(0x0216) / `GC_DEAD`(0x0217) / `GC_MOTION`(0x0307) / `GC_DAMAGE_INFO`(0x0410) / `GC_TARGET_INFO`(0x0A10) + `EPointTypes`HP/MAX_HP/SP/MAX_SP/LEVEL/EXP/GOLD…)+ `EDamageFlag`NORMAL/POISON/DODGE/BLOCK/PENETRATE/CRITICAL)。全部 `static_assert` 尺寸 | `extension/src/net/wire.h` |
| **`EntityStore`(封包→世界模型)** | ✅ headless。`apply()` 消化上述包 → `vid → Entity{race,name,x,y,z,angle,parts,func,moving, hp,max_hp,sp,max_sp,level,dead,stunned}``GC_MOVE` FUNC_MOVE 插值/其他 func 吸附;`tick()``now_ms` 插值。战斗:`GC_PLAYER_POINTS``points()` 全量属性 + 镜像到主角实体;`GC_PLAYER_POINT_CHANGE`→按 `EPointTypes` 更新单实体 vital`GC_DEAD`/`GC_STUN`→标记;拉取口 `drain_changes` / `drain_chat` / `drain_vitals` / `drain_damage` / `drain_motions` / `take_points_dirty` / `take_target_dirty`。单测 `net.entity_store` 扩展覆盖全部战斗包 | `extension/src/net/entity_store.{h,cpp}` |
| **`CG_*` 出站构造** | ✅ `GameClient::send_move/send_attack/send_target/send_chat``CG_CHAT` 变长) | `extension/src/net/game_client.h` |
| **环回集成测试** | ✅ `net.loopback_flow` CTest:进程内 `MockServer`(真协议:帧 + libsodium KX + 脚本相位包)驱动 `AuthClient``GameClient` 走完 auth→角色列表→选人→`PHASE_GAME`→游戏包进 `EntityStore`→NPC 移动插值。服务端一通改个 IP 即可 | `extension/tests/net_loopback_test.cpp` |
| **`EntityStore` 单测** | ✅ `net.entity_store` CTest:合成包 → spawn / 移动插值(半程/到达/func 复位)/ 吸附 / 变长 chat / despawn | `extension/tests/net_entity_test.cpp` |
| **`NetWorld` 场景桥(GDScript** | ✅ `net_world.gd`:消费 M2Client 实体信号,每 vid 一个子节点,位置向 `get_entity(vid).pos` 平滑逼近(超 `snap_dist` 瞬移)、`rotation.y` 取自 `angle_deg`、动画状态取自 `func`。默认「占位胶囊 + 名字牌 + HP 条」;`set_model_factory(cb)` 换真模型。**战斗**`vitals_changed`→缩放 HP 条 + emit `vitals(vid,hp,max_hp,dead)``damage`→实体头顶飘字(crit 放大变金 / dodge=MISStween 上浮淡出)+ emit `damage_number``entity_dead`→倒地 + `set_anim_state("dead")``main_entity_ready` / `chat_line` 信号。headless 自检 `netbridge_test.gd`spawn/main/远移吸附/近移插值/chat/伤害数字/HP 条/暴击/死亡/despawn)通过 | `project/net_world.gd` |
| **P0 闭环胶水 + 场景组装** | ✅ `net_play.gd``player_controller``M2Client` —— 点地→`move(FUNC_MOVE)`(节流)/ 停→`move(FUNC_WAIT)` / 点实体→`set_target` + 进 2.5m 自动 `attack` / 收自己 `GC_MOVE`→位置校正 / `points_changed`+`vitals_changed`+`target_info`→HUD。`hud.gd``set_vitals/set_exp/set_level/set_target/clear_target``game_scene.gd`(简版)把 world+player+camera+pc+net_world+hud+net_play+audio+lifecycle 拼起来,`login.gd``entered_game``--auto` 自动选槽)实例化。`netplay_test.gd` + `gamescene_test.gd` headless 全过 | `project/{net_play,game_scene,hud,login}.gd` |
| **P1 UI 工具层** | ✅ `ui/uiscript.gd` 解析原始 `uiscript/*.py`(子集解释器,**80/80 真实文件通过**)+ `ui/ui_build.gd` dict→Control 树(~20 种 type+ `ui/ui_assets.gd``.sub`/tga/png+ `ui/ui_manager.gd`(窗口栈 / ESC / modal / 拖动)+ `ui/dialogs.gd``ui_test.gd` 全过 | `project/ui/*.gd` |
| **P2 物品数据层** | 🟡 `wire.h``GC_ITEM_SET/DEL/UPDATE/USE/GROUND_ADD/GROUND_DEL/GET` + `CG_ITEM_MOVE/USE/DROP/PICKUP`(全 static_assert)。`EntityStore``inventory[90]` / `equipment[11]` / ground map + `drain_inv/ground/item_events``M2Client``get_inventory/equipment/item/ground_items` + `move_item/use_item/drop_item/pickup_item` + 5 信号。新 `Metin2Proto` 节点(`item(vnum)`/`mob(vnum)`,载 5748 真实 item)。⬜ 背包 / 装备窗 UI + 穿脱换模型 | `extension/src/{net/wire.h,net/entity_store.*,net/game_client.h,net/m2_client.*,proto/proto_node.*}` |
| **P3 聊天** | ✅ `wire.h` `GC_WHISPER`/`CG_WHISPER` + `EntityStore` whisper 解析(`ChatMsg` 加 from/sub+ `M2Client.whisper()` + `whisper_received` 信号。`ui/chat_ui.gd`4 标签 + 前缀 `/w`/`/g`/`/p`/`/s` + 系统/战斗日志。`net_world._bubble` 头顶气泡 + `main_bubble``chat_test.gd` 全过 | `extension/src/net/{wire.h,entity_store.*,game_client.h,m2_client.*}` · `project/ui/chat_ui.gd` |
| **P4 战斗表现** | 🟡 `wire.h` `GC_AFFECT_ADD/REMOVE` + `EntityStore` affect map + `M2Client.get_affects()` / `affect_added`·`affect_removed`。受击硬直(`net_play` + `pc.frozen`)· combo(motion 递增)· 相机抖动(`game_camera.shake`)· 死亡窗(`ui/death_ui.gd``/restart_here`·`/restart_town`)· `hud.set_affects` 图标条。`combat_fx_test.gd` 全过。⬜ 武器拖尾 / 相机锁定 | `extension/src/net/*` · `project/{net_play,game_camera,hud}.gd` · `project/ui/death_ui.gd` |
| **P5 特效引擎(首版)** | 🟡 `fx/mse.gd``.mse` 花括号树解析,真文件 6 particle)+ `fx/effect_player.gd`Particle→`GPUParticles3D`:发射形状/速率/寿命/曲线/颜色渐变/billboard/加法混合)+ `fx/effect_registry.gd`(名字→`.mse` + 缓存 + spawn)。`wire.h` `GC_SPECIAL/SPECIFIC_EFFECT` + `M2Client.effect_cue` 信号 → `game_scene` 在实体上播。`fx_test.gd` 全过。⬜ `.dds` 纹理 / `.mde` mesh / 绑骨骼 | `extension/src/net/*` · `project/fx/*.gd` |
| **P6 技能系统(首版)** | 🟢 `wire.h` `GC_SKILL_LEVEL/COOLTIME_END/CREATE_FLY` + `EntityStore` skill 表 + `M2Client.get_skills`/`cast_skill`/`skill_up` + `skills_changed`/`skill_cooldown_end`/`fly_cue` 信号。`ui/skill_table.gd`skilldesc.txt+ `ui/skill_ui.gd`K 键,加点)+ `ui/quickbar.gd`1-8 施放 + 冷却)+ `net_world` 飞行球。`skill_test.gd` 全过 | `extension/src/net/*` · `project/ui/{skill_table,skill_ui,quickbar}.gd` |
| **P7 任务 / NPC(首版)** | 🟡 `wire.h` `GC_SCRIPT`/`GC_QUEST_CONFIRM`/`GC_QUEST_INFO` + `CG_ON_CLICK`/`SCRIPT_ANSWER`/`SCRIPT_BUTTON`/`QUEST_INPUT_STRING`/`QUEST_CONFIRM`(全 static_assert)。`EntityStore``ScriptCue`/`ConfirmCue`/`QuestInfo` map + `drain_scripts/confirms/quest_changes` + `quest(idx)``Entity``ch_type``M2Client``click_npc`/`script_answer`/`script_button`/`quest_input`/`quest_confirm`/`get_quests` + `script_dialog`/`quest_confirm_ask`/`quest_info` 信号。`ui/quest_dialog.gd`EventManager 脚本渲染:`[ENTER]`/`[QUESTION]`/`[DONE]` → 文本+选项+确认)+ `ui/quest_log.gd`J 键)。`net_play._on_pick` NPC 分流。quest 逻辑纯服务器驱动(选型②)。`quest_test.gd` 全过。⬜ `[INPUT]` 输入 UI / 选道具 / 立绘 / 世界任务箭头 | `extension/src/net/*` · `project/ui/{quest_dialog,quest_log}.gd` · `project/net_play.gd` |
| charselect 串场 | ⬜ P10 | — |
| **P8 社交 / 商店 / 仓库(首版)** | 🟡 `wire.h` `GC_PARTY_*`(0x0710-16) / `GC_MESSENGER`(0x0741) / `GC_SHOP`(0x0810) / `GC_EXCHANGE`(0x051C) / `GC_SAFEBOX_*`(0x0830-34) + 对应 CG(全 static_assert`ShopItem`/`GCExchange`/`GCSubHeader` 等)。`EntityStore``PartyMember` map / `Friend` map / `ShopEntry` 表 / `ExchangeState` / `m_safebox[135]` + dirty 标志 + `drain_party_invites`/`drain_shop_errors``M2Client``party_invite/answer/leave` · `add/remove_friend` · `shop_buy/sell/close` · `exchange_start/add_item/add_gold/accept/cancel` · `safebox_checkin/checkout/move` + `get_party/friends/shop_items/exchange/safebox_items` + 8 信号。`ui/{party_ui,friend_ui,shop_ui,exchange_ui,safebox_ui}.gd`(队员面板常驻 / O 键好友 / 商店·交易·仓库随信号自动开)。`inventory_ui` 右键分流(商店卖 / 交易放 / 仓库存)。`p8_test.gd` + `net.entity_store` / `net.loopback_flow` 扩测全过。⬜ 公会(`PythonGuild` 40 法 + Mark 下载)、精炼 / 强化 / 龙魂、私人商店 / 拍卖、交易道具图标网格 | `extension/src/net/*` · `project/ui/{party,friend,shop,exchange,safebox}_ui.gd` · `project/game_scene.gd` |
| 公会 / 精炼 / 龙魂精炼 / 会徽下载(首版) | 🟡 `GC_GUILD`(0x0730) INFO/LIST/GRADE + `ui/guild_ui.gd`G,真机 `[GM-TEAM]`);`GC_REFINE_INFO`(0x051D) + `ui/refine_ui.gd``refine_ask``refine(pos,type)`);`GC_DRAGON_SOUL_REFINE`(0x051F)/`CG`(0x050D `grid[15]`) + `DS_SUB_*` + `EntityStore.DragonSoulCue`/`m_dragon_soul[180]` + `M2Client.ds_refine(mode,cells)`/`ds_window_open`/`ds_refine_result` + `ui/dragon_soul_ui.gd`(L / 自动弹,3 模式 + 15 格背包右键填)。**会徽**:`MARK_*` 独立连接(`GC_MARK_IDXLIST`/`BLOCK` 0x0C1x、`GC_MARK_UPDATE` 0x0B15、`CG_MARK_LOGIN/IDXLIST/CRCLIST` 0x0C0x);`NetStream``on_cipher_active()`/`on_raw()`u32 `buf_size` 分帧);`mark_image.{h,cpp}` `MarkImageSet`LZO1X → 512×512 → `mark_pixels`+ `mark_client.h` `MarkClient``M2Client.download_guild_marks`/`get_guild_mark_image``Image``serverinfo` `mark_port` 第 8 列;`app_flow` 进游戏连 + `guild_mark_updated` 冷却重连。**公会战 + 技能页**:`GUILD_GC_SKILL_INFO`(12) `GuildSkillInfoBody`(17)、`WAR`(15) `GCGuildWar`(10)、`GUILD_WAR_LIST`/`END_LIST`(17/18) `GuildWarPair`(8)、`WAR_POINT`(19) `GuildWarPoint`(12)、`NAME`(16) `GuildNameEntry`(16)`EntityStore` `GuildSkillState`/`GuildWarStatus`/`m_guild_wars`/`m_guild_names` + `drain_guild_war_events`/`_scores``GameClient.send_guild_use_skill``M2Client` `get_guild_skill`/`get_guild_wars`/`get_guild_war`/`use_guild_skill`/`declare_guild_war`(`/war` 命令) + `guild_skill_changed`/`guild_war_changed`/`guild_war_event`/`guild_war_point` 信号;`ui/guild_ui.gd` 改三页 Tab(成员/技能/公会战)。**会徽上传**:`CG_MARK_UPLOAD`(0x0C03) `CGMarkUpload`(776)、`CG_GUILD_SYMBOL_UPLOAD`(0x0722) `CGSymbolUpload`(8)+文件字节;`NetStream.send_pending()``MarkClient` `Mode`(Download/UploadMark/UploadSymbol) + `upload_done()``M2Client.upload_guild_mark`(转 RGBA8/缩放)/`upload_guild_symbol`/`get_mark_server` + `guild_mark_uploaded``guild_ui` 成员页「上传会徽」按钮。`net.loopback_flow``MockServer::Mode::Mark` 真 socket 端到端校验(KX → CG_MARK_LOGIN → CG_MARK_UPLOAD 768 字节校验和 → upload_done)。`guild_refine_test.gd` + `dragon_soul_test.gd` + `guild_mark_test.gd` + `guild_war_skill_test.gd` + `net.entity_store` + `net.guild_mark` + `net.loopback_flow`(CTest) 全过。**真机验证(2026-08-31`net_e2e`**`GUILD_GC_SKILL_INFO`(sub 12) 服务器进游戏自动推送,17B body 解析正确(skill_point=19 / guild_point 0/2300 / 12 等级);`GUILD_GC_WAR`(sub 15) 无战状态解析正确;trace 显示 `GC_GUILD`(0x0730) 多个子包 len=171/40/83/22/21/9/5 均无 unknown / parse 错误。会徽独立连接 + 会徽上传仍未接真机(联调服未开会徽服端口)。⬜ 龙魂属性表、公会等级/日志页 | `extension/src/net/*` · `project/ui/{guild,refine,dragon_soul}_ui.gd` · `project/{net/serverinfo,app_flow}.gd` |
| **私人商店 / 道具商城 / Cube(首版)** | 🟡 `wire.h``CG_MYSHOP`(0x0802) `CGMyShopHead`(38`char sign[33]+u8 count`) + `MyShopItem`(13`u32 vnum,u8 count,ItemPos,u32 price,u8 display_pos`)×N`CG_MALL_CHECKOUT`(0x0840) `CGMallCheckout`(8)`GC_MALL_OPEN`(0x0841) `GCMallOpen`(5)、`GC_MALL_SET`(0x0842)/`GC_MALL_DEL`(0x0843) 复用 `GCItemSet`/`GCItemDel`,全 static_assert。`GameClient``send_open_private_shop(sign, vector<MyShopItem>)`(打包头+N项)/ `send_close_private_shop`(=`SHOP_CG_END`) / `send_mall_checkout` / `send_cube_make|material_info|result_list``/cube make|mInfo|rList``send_chat(CHAT_TYPE_COMMAND,…)`)。`EntityStore``m_mall[135]` + `mall_open/size/slot/dirty``GC_CHAT``type==CHAT_TYPE_COMMAND` 不进聊天,转 `apply_server_command()` 解析 `cube open/close/info/success/fail/r_list/m_info``v,c` 结果列表 + `@`配方 `&``|``/`金币)→ `CubeState{open,npc,recipes[],results[],need_*}` + `drain_cube_events()``M2Client``open/close_private_shop``get_mall_items`/`mall_checkout``get_cube`/`cube_make`/`cube_request_result_list`/`cube_request_materials` + `mall_opened`/`mall_changed`/`cube_opened`/`cube_closed`/`cube_changed`/`cube_result` 信号。`ui/private_shop_ui.gd`(招牌 + 背包挑件 + 定价 + 개설/철수)、`ui/mall_ui.gd`(列表 + 取出到背包空格)、`ui/cube_ui.gd`(配方列表 + 材料/金币 + 제작);`game_scene` 装配三窗。`net.entity_store`(合成 GC_MALL_* + `GC_CHAT/COMMAND` cube 全家桶)+ `net.loopback_flow`(真 socketserver 推 mall+cubeclient 发 `CG_MYSHOP`/`CG_MALL_CHECKOUT`/`/cube` 字节校验)+ `shop_cube_mall_test.gd`(三窗 FakeClient)全过。**真机验证(2026-08-31`net_e2e MT_E2E_MYSHOP=1`**`CG_MYSHOP`(0x0802) 38B head + 13B item×1vnum 149price 99999999)被真服接受,无断连 / 无 shop error`SHOP_CG_END` 关店正常。Cube `/cube rList``CG_MALL_CHECKOUT` 送出路径不断连,但服务器只在玩家站到对应 NPC 前才回应,`mall_open=0` / `cube open=0` —— 需 NPC 交互才能完整验。⬜ `GC_SHOP_SIGN`/`SYMBOL_DATA` 头顶招牌、他人开店 `START_EX` 多标签、39 格拖放网格 / 鼠标物品态、能量条、NPC 前完整验 cube/mall。**拍卖行**m2dev-client 无此系统(`0x08xx` 只有 shop/myshop/safebox/mall),非移植项,需自定义协议 + 服务端 | `extension/src/net/{wire.h,game_client.h,entity_store.{h,cpp},m2_client.{h,cpp}}` · `project/ui/{private_shop,mall,cube}_ui.gd` |
| **P9 世界系统(首版)** | 🟡 `wire.h` `GCWarp`(18) / `GCTime`(12) / `GCChannel`(5) / `GCNPCPosition`(6+78×n) / `GCTargetCreate/Update/Delete`(46/16/8) / `GCMount`(21),全 static_assert。`EntityStore``WarpCue` + `drain_warps``server_time`/`channel` + dirty、`m_npc_marks` / `m_markers` map、`Entity.mount_vnum` + `drain_mount_changes``M2Client``get_channel/get_server_time/get_npc_marks/get_world_markers` + `warp`/`time_changed`/`channel_changed`/`npc_marks_changed`/`world_markers_changed`/`mount_changed` 信号。`ui/minimap.gd`(右上圆形,北朝上,实体/NPC/任务标记 + 玩家箭头 + CH 角标)+ `world/world_time.gd`epoch → 太阳角度/光色/ambient 昼夜)+ `fx/weather.gd`(雪/雨 GPUParticles 跟相机)。`game_scene._on_warp` 同服挪人。`p9_test.gd` + `net.entity_store` 扩测全过 | `extension/src/net/*` · `project/ui/minimap.gd` · `project/world/world_time.gd` · `project/fx/weather.gd` · `project/game_scene.gd` |
| **P10 网络补全(首版)** | 🟢 `wire.h` `CGClientVersion`(0x000D70B)。`GameClient``GC_MAIN_CHARACTER` → 自动发 `CG_CLIENT_VERSION` = **打通 LOADING→PHASE_GAME**(真机验证进游戏 + 121 实体)。`net/serverinfo.gd``class_name ServerInfo`,TSV 服务器表 + 频道端口偏移)+ `ui/loading_screen.gd`(相位驱动读取遮罩)+ `ui/reconnect_ui.gd`(断线倒计时 + 自动/手动重连)+ `app_flow.gd`LOGIN→SELECT→GAME 串场状态机,单 `M2Client` 贯穿)。attack CRC 全 0 实测服务器接受。`net_e2e` 加 move+attack poke`net.loopback_flow` 断言自动版本报文;`p10_test.gd` 全过 | `extension/src/net/{wire.h,game_client.h}` · `extension/tools/net_e2e.cpp` · `project/{app_flow,net/serverinfo}.gd` · `project/ui/{loading_screen,reconnect_ui}.gd` |
| 大地图窗 / 地形贴花 / 钓鱼 / 副本 / 坐骑换模型 / 跨服换图 / 频道人数轮询 / 真机字节校准 | ⬜ 见 [`CLIENT-ROADMAP.md`](./CLIENT-ROADMAP.md) P9·P10 剩余 | — |
| 物品 / 装备(`item_proto` + 背包封包 + 装备挂模型 → 解锁 §2.7 高光 / §2.3 时装) | ⬜ | — |
## 3. 连通性(2026-08-30:真机端到端打通到 PHASE_GAME
服务端 = **m2dev-fork 协议**`net_e2e``extension/tools/net_e2e.cpp``build/extension/net_e2e`
驱动 auth→game→选人→进游戏全程;`MT_NET_TRACE=1` 打印每包 header/length/consumed。
**真机结果 —— 全程打通**
- ✅ auth 握手(libsodium KX+ `CG_LOGIN3``GC_AUTH_SUCCESS`
- ✅ game `CG_LOGIN2`**`GC_LOGIN_SUCCESS4`(0x01054 槽)** 角色列表(`[SA]Admin` lv105
-`CG_CHARACTER_SELECT``GC_PHASE(LOADING)` → 整包洪流全部按正确字节解析
-**`GC_MAIN_CHARACTER` 后自动发 `CG_CLIENT_VERSION`(0x000D)** → `GC_PHASE(GAME)` / **IN GAME**
- ✅ 进游戏后:121 个实体、HP 14990/15178、level 105、20 格背包、好友列表 1 人
-`CG_MOVE` + `CG_ATTACK`CRC 全 0)保持连接不被踢 → **不需要占位 CRC**
**修的 3 个真 bug(对任何真服务器都必需)**
1. `GC_LOGIN_SUCCESS4`(0x0105) 没处理(只认 3 槽的)→ 加 `GCLoginSuccess4`(492B) + handler。
2. 帧分发器 "did not consume" 误判:连续同 header 的包(一串 `GC_PLAYER_POINT_CHANGE`)会误杀连接
→ 改成按**实际消费字节数**判定(`net_stream.cpp` dispatch())。
3. **缺 `CG_CLIENT_VERSION`**:服务器收到 `GC_MAIN_CHARACTER` 后等客户端回版本报文,缺它就在
phase 4 断线 → `GameClient` 收到 `GC_MAIN_CHARACTER` 时自动发(fork 固定 timestamp `1215955205`)。
**真机字节校准(2026-08-30,已做)** —— 用 `MT_NET_DUMP=1 net_e2e` 抓真实封包逐字节对:
- **实体名 / 等级 / 公会 / 骑乘** ✅ 走 `GC_CHAR_ADD_INFO`(0x0207`TPacketGCCharacterAdditionalInfo`
97B),之前根本没处理。加 `GCCharAddInfo` struct + handlerNPC「Old Man」(race 20009) 名字正确出。
- **`GC_CHARACTER_UPDATE`**(0x020938B) ✅ 之前只粗解 parts;现按 `TPacketGCCharacterUpdate` 全解
parts / guild / alignment / pk_mode / mount`GCCharacterUpdate` struct)。
- **`ch_type`**`GC_CHARACTER_ADD.bType` 对 NPC/怪都是 0(服务器不填)。改在 GDScript 按 `race`
`mob_proto.type` 分类(`net_play._entity_kind`0 怪 / 1 NPC / 2 石头 / 3 warp),`_entity_name`
怪名回退 `mob_proto.locale_name``game_scene` 现在也载 `mob_proto`
- **`GC_PLAYER_POINTS`**(0x02141024B) —— **本来就对的**。真机 gold=13.5 亿(GM 号)、exp=0、
next_exp=int32 溢出为负都是服务器真实值。`M2Client.get_points` 把 exp/next_exp 按 unsigned 输出。
**第二轮校准(2026-08-30,已做)** —— 又抓了 in-game 洪流里没对过的包:
- **`GC_SKILL_LEVEL_NEW`**(0x021B1534B) ✅ 这才是本服发的技能包(**没发老的 0x021A**),之前
`get_skills()` 永远空、K 键技能窗没内容、快捷栏没法施放。加 `PlayerSkill{u8 master, u8 level,
u32 next_read}`6B/条,fork 的 `time_t` 是 32 位)+ `GCSkillLevelNew` + handler。真机实测:33 个
技能全 lvl 40 master 3GM 号)。`get_skills()``master`0 普/1 M/2 G/3 P)。
- **`GC_QUICKSLOT_ADD/DEL/SWAP`**(0x0519/1A/1B) ✅ 之前没处理 → 快捷栏进游戏是空的。加
`QuickSlot{u8 type,u8 position}` + 3 个 handler + `M2Client.get_quickslots()` + `quickslots_changed`
`ui/quickbar.gd.restore_from_server()` 按服务器内容填 1-8 号格(type 1 道具 / 2 技能)。真机 10 格。
- **`GC_NPC_POSITION`**(0x0A50) —— struct **本来就对**6 + 78×nname[65])。真机 49 条 NPC,名字对
(服务器不 zero-pad name buffer`strnlen` 已兜住)。
- **`GC_ITEM_UPDATE`**(0x051441B) / **`GC_CHAT`**(0x060310B 头) / **`GC_AFFECT_ADD`**(0x0A2025B)
/ **`GC_SPECIAL_EFFECT`**(0x0A309B) / **`GC_QUEST_INFO`**(0x09127B) —— 逐字节核对,**全部已对**。
- **`GC_GUILD`**(0x0730) sub-header 包(grade/info/list)已解析(见 M4 行);会徽走 `MARK_*` 独立连接。
**校准后续(2026-08-30,已做)**
- **3D 名字标签刷新** ✅ `EntityStore``ChangeKind::Info``GC_CHAR_ADD_INFO` 到达时对已存在实体
`Info` 变更 → `M2Client.entity_info(vid, dict)` 信号 → `net_world._on_info` 刷 Label3D。
`net_world.name_resolver``game_scene` 设为 `net_play._entity_name`)让怪名走 `mob_proto`
- **跨服换图 `GC_WARP` addr≠0** ✅ `M2Client``WarpCue.addr` 是网络序 IPv4,转点分十进制后
`warp_to_game_server(host, port)` —— 拆掉当前 game 连接、复用存下的 `login_key` 直连新 game
server(跳过 auth)。同服 warp 仍只挪玩家。
- **断点快连** ✅ `M2Client.reconnect()` 现在优先用 `last_login_key` 直连 game server(省一次
auth 往返),失败再走完整 auth→game。服务器重发 spawn = 断点续连。
- **频道负载查询** ✅ `net/channel_status.gd`(对齐 `CServerStateChecker`):明文 TCP 连任一频道口
→ 发 `CG_STATE_CHECKER`(0x000Flen 4) → 跳过杂包到 `GC_RESPOND_CHANNELSTATUS`(0x0010) →
`int32 count` + count×`{i16 port, u8 status}`。status0 关 / 1 正常 / 2 拥挤 / 3 爆满
(Metin2 协议里就是 4 态,不是在线人数)。`app_flow._probe_channels` 先查状态、没答上的频道回退
`_probe_tcp`,下拉项标 ●/◐/✕。**真机实测**:`192.168.21.203:11011``{11011:1, 11012:1,
11013:1, 11991:1}` —— 也据此把 `serverinfo` 的频道端口步长从 +10 改成 **+1**(可在 serverlist.txt
第 7 列 `port_step` 按服覆盖)。`channel_status_test`(进程内 mock 服务器:请求字节 + 跳杂包 +
解析 + 不可达返回空)。
- **怪 / NPC 真 3D 模型** ✅ `ui/mob_view.gd`(对齐 `player_view.gd`):`race` = `mob_proto` vnum →
**`root/npclist.txt`**`<vnum>\t<代号>\t…`,如 `113→bear_brown``185→tiger_big``102→wolf`
翻译版 `mob_proto``szName` 也本地化成 "Brown Bear" 了,解不出目录,必须靠这张表,`mob_proto`
名仅作兜底)→ 目录按代号逐段回退(`bear_brown → bear`),网格 / 贴图用完整代号
(换肤网格都在基础目录里:`bear/bear_brown.gr2` + `bear/bear_brown.dds`)→ 进 `Metin2Model`
texture_dir = 目录)+ 解 `motlist.txt``Metin2AnimPlayer`。扫
`<assets>/*/ymir work/{monster,monster2,npc,npc2}/<folder>/`。npclist 解析一次进 `static` 缓存。
`set_anim_state("wait/run/attack/damage/dead/skill")` 按 motlist 动作名映射。
`game_scene._make_entity_model` 设为 `net_world` 的默认工厂(非主角 + 有 mob_proto 时);race→是否有模型
缓存,失败回退占位胶囊。`net_world._attach_nameplate` 抽出来,真模型也挂头顶名字 + HP 条。
`mob_view_test`race 102 Wolf → `wolf/` 目录、Metin2Model40 骨 762 顶点)、motlist ≥3 动作、状态切换。
实测进游戏「Alpha Grey Wolf」「Hungry Black Bear」等出真模型 + 贴图 + 动作 + 名字牌 / 血条。
- **重连 / 已在局内时补拉实体** ✅ `game_scene.setup()` 现在是协程(分帧避免阻塞漏 PONG),
`net_world.setup()``entity_spawned` 已晚于 `M2Client` 的初始 spawn 洪流 → `_on_spawn` 0 次触发。
修:`net_world.catch_up()` 遍历 `client.get_entities()` 补 spawn 未镜像的;`game_scene` 已在局内分支里
**先** `net_world.set_local_vid(get_main_vid())` + `net_play._on_main_set(get_main_vid())`
`catch_up()`,否则本地玩家会被画成一个压在身上的大蓝胶囊。
**剩的都不是代码问题**:字面「在线人数」(数字)Metin2 协议里没有 —— `bStatus` 就是 4 态负载指示,
要真数字得改服务器;本地 `assets/` 只有 ~85 个 monster 目录(不是全量),没目录的 race
仍是占位胶囊 —— 换全量资产包即可全部出真模型。
**联调路上修的两个真 bug**(对任何真实服务器都必需):
1. **`GC_LOGIN_SUCCESS4`(0x0105) 没处理** —— 只认 3 槽的 `GC_LOGIN_SUCCESS3`。已加 `GCLoginSuccess4`
结构(492B) + handler。
2. **帧分发器误杀连接** —— 连续同 header 的封包(如一串 `GC_PLAYER_POINT_CHANGE`)会触发
"on_packet did not consume" 误判并断线。改成按**实际消费字节数**判定
`net_stream.cpp` dispatch())。此 bug 之前会让 P0–P8 对任何真服务器的端到端全挂。
**当前状态**:P0–P8 的封包层仍以合成封包 CTest + FakeClient GDScript 测试离线验证(14 套 GDScript +
9 CTest 全绿)。真机端到端到 PHASE_GAME 的最后一跳留给 P10。
## 3b. B 轨(不依赖服务端的客户端系统)
| 模块 | 状态 | 位置 |
|---|---|---|
| **eterpack 读写器**G1| ✅ 本 fork = `PackLib` 单文件包(header + XChaCha20 加密索引 + zstd 数据 + 可选逐文件加密,硬编码 `PACK_KEY`)。`EterPack`(读,name-field 从 header 反推兼容真实包)+ `write_pack` + `packtool` CLI。`pack.roundtrip` CTest + 真实 `textureset/` 45 文件明文/加密往返 `diff` 一致 | `extension/src/pack/eterpack.{h,cpp}``extension/tools/packtool.cpp` |
| **登录 / 选人界面**(首版)| ✅ `charselect.gd`3 旋转台 × 真实职业模型(`<class>_novice.gr2` + `wait.msa`+ 真实 `board` 九宫格面板(槽位按钮 + 进入游戏)。数据来自 `M2Client.char_list`(联机)或 `--mock`(离线开发)。`login.gd`:服务器/账号/密码 + 连接,接全部 `M2Client` 信号(`--auto` 测试)。`ui_kit.gd`:从 `hud.gd` 抽出的九宫格助手。⬜ 创建/删除角色、旋转台微调、login→charselect→world 串场 | `project/{login,charselect,ui_kit}.gd` |
| **正式选人页(Metin2 风)** | ✅ `ui/char_select_screen.gd``app_flow._build_char_list` 用它替掉原来的裸按钮列表。全屏背景 `d:/ymir work/ui/intro/select/select.sub`=select.jpg+ 右侧 `SubViewport``own_world_3d`)里 `PlayerView` 真模型(正面朝相机 + `wait` + 缓摆)+ 左上职业名(locale `name_<class>.sub``select.dds`DDS 走 `Metin2World.load_dds`;解不出退中文描边字 猛将/刺客/术士/巫女)+ 左侧 `board` 信息板(帮会徽 `get_guild_mark_image` / **国家名 `get_empire()`→`EMPIRE_A/B/C` locale + 国旗 `empireflag_<a/b/c>.sub`** / 帮会名 / 名称 / 等级 / 游戏时间 / 体力·智力·力量·敏捷 条)+ 开始 / 创建 / 删除 / 退出 + 多角色 ◀ ▶ 槽位切换(`_pad_slots``get_slot_count()` 补空槽,◀▶ 能走到空槽建号)。**建号 / 删号(1:1 对齐 `PythonNetworkStreamPhaseSelect.cpp`**`CG_CHARACTER_CREATE`(0x0201) `CGCreateCharacter`(77job 是 **u16**)、`CG_CHARACTER_DELETE`(0x0202) `CGDeleteCharacter`(13`private_code[8]`)、`GC_PLAYER_CREATE_SUCCESS`(0x020C) `GCPlayerCreateSuccess`(108=`{u8 slot, SimplePlayerInfo}`)、`GC_PLAYER_CREATE_FAILURE`(0x020D) `{u8 type}``GC_PLAYER_DELETE_SUCCESS`(0x020E) `{u8 slot}``GC_PLAYER_DELETE_WRONG_SOCIAL_ID`(0x020F)。`GameClient``create_character`/`delete_character` + `drain_char_events()``CharEvent{CreateOk/CreateFail/DeleteOk/DeleteFail}`+ `replace_slot`/`clear_slot`(服务器只补丁槽位不重发列表)+ `slot_count()``M2Client``create_character(slot,name,job,shape,con,int,str,dex)`/`delete_character(slot,code)`/`get_empire`/`get_slot_count` + `char_created`/`char_create_failed`/`char_deleted`/`char_delete_failed` 信号(建 / 删成功后重发 `char_list`)。选人页建号弹窗(职业下拉 + 名称 + 各职业起始四维只读)、删号弹窗(secret 删除码);`app_flow._on_char_list` 在 SELECT 态只 `set_chars()` 不整屏重建。会徽:`app_flow._goto_select` 也发 `download_guild_marks``guild_marks_ready`(one-shot) → `refresh_crest()`。相机改按**包围球** `r/sin(fovV/2)` 自适应 + 沿 -X 推 `r*0.5`(横向视锥更宽,不切远侧手),蒙皮定型后多 fit 4 帧。C++:`CharSlot` + `char_list` dict 补 `play_minutes/st/ht/dx/iq/main_part/hair_part``ui_assets.gd``load_dds_image()``net.loopback_flow` MockServer 加 `CG_CHARACTER_CREATE/DELETE` 真 socket 端到端(名称字节校验 + 成功补丁槽位 + 名称冲突 fail + 错误删除码拒绝);`char_create_delete_test.gd`(弹窗 → 假 client 收 `create_character(slot1,"Newbie",job1,con3/int3/str4/dex6)` + 短名拒绝 + `char_created` 关窗 + 删号码 + 失败清框 + `set_chars` 保持选中);`p10_test` 选人断言改点「开始」 | `project/ui/char_select_screen.gd` · `project/ui/ui_assets.gd` · `project/app_flow.gd` · `extension/src/net/{wire.h,game_client.h,m2_client.{h,cpp}}` · `extension/tests/net_loopback_test.cpp` |
| **角色状态窗(1:1 窗口迁移样板)** | ✅ `project/ui/char_status_ui.gd``UiScript`+`UiBuild` 直接装载 `assets/uiscript/uiscript/characterwindow.py` 真布局(4 页 tabSTATUS/SKILL/EMOTICON/QUEST`SetState` 切页;技能 / 表情 / 任务的数据仍走各自专用窗)。STATUS 页数值逐字对照 `assets/root/uicharacter.py.RefreshStatus``Level_Value`=`points[LEVEL]``Exp_Value`=`u32(exp)``RestExp_Value`=`u32(next_exp)-u32(exp)``HP/SP_Value`=`cur/max``STR/DEX/HTH/INT_Value`=`points[ST/DX/HT/IQ]``ATT_Value`=`(MIN_ATK|MAX_ATK)+ATT_GRADE_BONUS+PARTY_ATT_GRADE`min==max 取单值,全 0 退 `ATT_POWER`)、`DEF_Value`=`DEF_GRADE(+DEF_GRADE_BONUS 若非 0)``MATT_Value`=`MAGIC_ATT_GRADE+(MIN|MAX)_MAGIC_WEP``MDEF_Value`=`MAGIC_DEF_GRADE``ASPD/MSPD/CSPD/ER_Value`=`points[ATT_SPEED/MOV_SPEED/CASTING_SPEED/EVADE_RATE]`EPointTypes 索引取自 m2dev `src/UserInterface/Packet.h`)。加点:`HTH/INT/STR/DEX``_Plus`/`_Minus` 按钮 → `client.say(0, "/stat ht"|"/stat- ht" …)`,与原 `statusPlusCommandDict`/`statusMinusCommandDict` 完全一致;`Status_Plus_Value`=`points[STAT]``>0` 才显示 `_Plus` 按钮和 `Status_Plus_Label`。名称 / 帮会 / 职业头像取 `get_entity(get_main_vid())``race%4``face_warrior/assassin/sura/shaman.sub`)。`points_changed` 信号刷新。`game_scene``char_status_ui` 节点 + V / C 热键。`char_status_ui_test.gd`:假 client 灌 points → 断言全部 Value label 文本 + `/stat` 命令 + `STAT==0` 隐藏加点按钮 + tab 切换页可见性。后续选道具网格 / 商店 START_EX / 私人商店 39 格照抄这套(uiscript 装载 + 逐字数值绑定 + 原聊天命令 / 封包)| `project/ui/char_status_ui.gd` · `project/game_scene.gd` · `project/char_status_ui_test.gd` |
| **组队成员信息板(`uiparty.PartyMemberInfoBoard`** | ✅ `wire.h``CGPartySetState{u16 hdr,u16 len,u32 pid,u8 role,u8 on}`static_assert 10+ `EPartyRole` 枚举(NORMAL 0 / LEADER 1 / ATTACKER 2 / TANKER 3 / BUFFER 4 / SKILL_MASTER 5 / BERSERKER 6 / DEFENDER 7PythonPlayer.h)。`game_client.send_party_set_state(pid,role,on)``CG_PARTY_SET_STATE` 0x0704,注意结构字段名 `dwVID` 实际传 PID —— 对齐 `uiparty.py OnSelectState``net.SendPartySetStatePacket(self.pid,...)`)。`M2Client.party_set_state(pid,role,on)` + bind`get_party()` dict 补 `state`(完整角色字节,bit0=leader+ `affects[7]`int×7)。`project/ui/party_ui.gd` 从「名字+一条 HP」重做成 `partymemberinfoboard.py` 布局(每员 106×36 条:StateButton 22px 角色图标 + NameSlot + Gauge + 附加图标行):`_strip(m)` → 角色状态按钮(`_local_is_leader()` 才可点 → `_open_role_popup` 弹 普通/攻击/坦克/狂战/辅助/宗师/防御 + 踢出;普通=`party_set_state(pid, cur_role, false)` 清角色;踢出=`party_leave(pid)`=`SendPartyRemovePacket`+ 名字(★队长) + HP `ProgressBar` + `affects[i]!=0` → chip`AFFECT_LABEL` 暂定 经验/攻击/防御/辅助/宗师/时间/回复,tooltip 带值)。点名字 → `set_target(vid)``OnMouseLeftButtonDown` 选中)。header:EXP 分配开关 `_toggle_distribute``party_set_distribute``EPartyExpDistributionType` 0 NON_PARITY / 1 PARITY+ 组队治疗 `_party_heal``party_use_skill(PARTY_SKILL_HEAL=1, 0)``_local_is_leader()` = `get_party()``vid == get_main_vid()` 那条的 `leader`。角色菜单按党技能等级门控(`uiparty.__ShowStateButton` Tanker≥10…Defender≥40)暂略。`p8_test` 组队段扩:affect chip 渲染 + 队长点 StateButton 弹菜单 + 「攻击」→ `party_set_state(pid, 2, true)` + 踢出 → `party_leave` + 分配切换 → `party_set_distribute(1)` + 治疗 → `party_use_skill(1,0)` + 点名字 → `set_target` + 非队长 StateButton disabled。**顺带修 `p8_test._init``await _run()` 的隐患**(第一个 `await` 之后所有断言从未生效,含增量 34 `shop_ex`)→ 改 `await` 后暴露 `shop_ui.refresh()` `queue_free` 延迟 bug(旧 row 当帧未消失),改 `remove_child`+`queue_free` | `extension/src/net/{wire.h,game_client.h,m2_client.{h,cpp}}` · `project/ui/party_ui.gd` · `project/ui/shop_ui.gd` · `project/p8_test.gd` |
| **ESC 系统菜单 + 游戏设置窗(`uisystem.SystemDialog` + `uigameoption.OptionDialog`** | ✅ `project/ui/system_menu_ui.gd``systemdialog.py` 真 uiscriptthinboard 8 键)= `uisystem.py __LoadSystemMenu_Default` 1:1`system_option_button``system_option_ui.open()``game_option_button``game_option_ui.open()``change_button``client.say(0,"/phase_select")``net.ExitGame()` 在有 ping 时发 `SendChatPacket("/phase_select")`),`logout_button``"/logout"``net.LogOutGame()`),`mall_button``"/in_game_mall"``exit_button``get_tree().quit()``help_button`→占位 `toast``cancel_button`/标题栏→关。modal 打开。`game_scene`ESC 从直接 `system_option_ui.toggle()` 改成 `system_menu_ui.toggle()`。<br>`project/ui/game_option_ui.gd``gameoptiondialog.py` 真 uiscript= `uigameoption.py``BLOCK_BITS` = `block_{exchange,party,guild,whisper,friend,party_request}_button`(toggle_button) → `_toggle_block(bit)``_block_mode ^= bit` + `client.say(0, "/setblockmode %d" % _block_mode)``Packet.h EBlockAction``1<<0..1<<5`,逐字对照 `__OnClickBlock*``net.SendChatPacket("/setblockmode " + str(blockMode ^ player.BLOCK_*))`+ 持久化 `[gameopt] block_mode`(服务器回包同步待补)。`PK_CMD` = `pvp_{peace,revenge,free,guild}`(radio) → `client.say(0,"/pkmode %d")``{0,1,2,4}``__OnClickPvPMode*`guild 是 `/pkmode 4``PK_MODE_PROTECT`=3 跳过)。`DISPLAY_RADIOS``name_color`/`target_board`/`view_chat`/`always_show_name`/`show_damage`/`salestext` 的 on/off/normal/empire radio → `_display[key]` + 持久化 `user://system_option.cfg [gameopt]`(等价 `systemSetting.Set*Flag`;渲染侧钩子——头顶名字配色 / 伤害数字 / 聊天显隐——待补)。`_bind_radio` 处理 radio 배타성,`_sync`/`_sync_block` 开窗回填,`_relabel` 中文兜底(uiScriptLocale 未接全局 Locale)。`system_menu_ui_test.gd`:两窗装载 + `/setblockmode 1``3``2`(位 XOR+ `_block_mode` 跟踪 + `/pkmode 1`/`4` + radio 배타 + 显示开关落 cfg + 二次实例读 `block_mode` 回填按钮态 + menu 各键 `/in_game_mall`/`/phase_select`/`/logout` + `system_option_button`/`game_option_button` 开子窗关菜单 + `cancel` 关。C++ 无改动 | `project/ui/system_menu_ui.gd` · `project/ui/game_option_ui.gd` · `project/game_scene.gd` · `project/system_menu_ui_test.gd` |
| **系统设置窗(`uisystemoption.OptionDialog`** | ✅ `project/ui/system_option_ui.gd``UiScript`+`UiBuild``assets/uiscript/uiscript/systemoptiondialog.py`board + titlebar + `music/sound_volume_controller` sliderbar + `camera_short/long` + `fog_level0/1/2` + `tiling_cpu/gpu` + `tiling_apply` + `bgm_button`)。控件绑定逐字对照 `assets/root/uisystemoption.py``OnChangeMusicVolume``snd.SetMusicVolume`+`systemSetting.SetMusicVolume`(接 `Audio.master_bgm` + 即时改在播 `_bgm[_bgm_cur].volume_db`),`OnChangeSoundVolume``Audio.master_sfx``play_ui`/`play_at` 每次读),`camera_short/long``game_camera.max_dist``CAMERA_MAX=[11,20]`,并 `dist=min(dist,max)`),`fog_level0/1/2`(浓/중/淡)→`__SetFogLevel` = `Environment.fog_enabled=true`+`fog_density=[0.055,0.018,0.004][i]``tiling_*`/`tiling_apply`(原 CPU/GPU 分块渲染 + `net.ExitGame` 重启)Godot 渲染器无对应 → 保留占位。`_bind_radio` 处理 radio 배타성。持久化 `user://system_option.cfg`section `audio`/`video`= `systemSetting` 配置文件);`setup()` 读回并 `_apply_all()` 即时生效;`game_scene._on_main_set``.msenv``Environment` 后再 `_apply_all()``game_scene`ESC 呼出(`ui_manager._unhandled_input``close_top()` 吃事件,无窗时才落到 `game_scene`)。uiScriptLocale 未接全局 Locale → `LABELS` 中文兜底。`system_option_ui_test.gd`:装载 + `music/sound` slider `value_changed` 已连 + `_on_*_volume` 应用到 `master_bgm/sfx` + camera radio→`max_dist`+dist 夹 + fog radio→`fog_density` + cfg 落盘 + 二次实例读 cfg 即时应用。C++ 无改动 | `project/ui/system_option_ui.gd` · `project/game_scene.gd` · `project/system_option_ui_test.gd` |
| **私人商店开设窗(`PrivateShopBuilder` 模型)** | ✅ `project/ui/private_shop_ui.gd` 从「前 5 行 checkbox」重做成 1:1 复刻 `assets/root/uiprivateshopbuilder.py`(**增量 36:布局改走真 uiscript `privateshopbuilder.py` —— board + TitleBar + `NameLine`(盖 LineEdit) + `ItemSlot` 5×8=40 grid + Ok/Close;背包候选面板挂窗右侧**):`_stock` = `itemStock``{格号 -> {cell,vnum,count,price}}`)。左侧 `_inv_list` 背包候选(排除已上货的 cell),点一件 → `_pick``_picked`= `mouseModule` attached item);点右侧 40 格(`GRID_SLOTS=40` = `shop.SHOP_SLOT_COUNT`5×8)空格 → `_ask_price``uiCommon.MoneyInputDialog``SpinBox`)→ `_place(slot, price)``_stock`= `AddPrivateShopItemStock` + `itemStock[targetSlotPos]=(src)`);点已占用格 → `_stock.erase` = `OnSelectItemSlot` / `DelPrivateShopItemStock`)。`_ok()`OkButton → `BuildPrivateShop`):`_stock` 按格号 `sort()``display_pos = 格号``items.size() >= PRIVATE_SHOP_ITEM_MAX(39)` 截断 → `client.open_private_shop(sign, [{vnum,count,inv_cell,price,display_pos}])``MyShopItem`/`TShopItemTable` 字段 1:1`{u32 vnum, u8 count, ItemPos pos, u32 price, u8 display_pos}`)。`_close_shop()` = `close_private_shop()` = `SHOP_CG_END`。测试拆成独立 `private_shop_ui_test.gd`(真 uiscript,缺资产跳过):`_cells` 40 格 / `NameLine` 盖 LineEdit / 2 候选 / 空 stock 不发 / 拿起→价格弹窗→`_place` / 개설按格号排序(格 0 在格 3 前)+ `display_pos` / 点占用格撤下 / 철수;`shop_cube_mall_test` 私人商店段移除。C++ 无改动(`open_private_shop` / `MyShopItem` 早已就位)| `project/ui/private_shop_ui.gd` · `project/game_scene.gd` · `project/private_shop_ui_test.gd` · `project/shop_cube_mall_test.gd` |
| **`SHOP_GC_START_EX` 多货架商店** | ✅ `wire.h``ShopTabHead{char name[SHOP_TAB_NAME_MAX=32]; u8 coin_type}`static_assert 33+ `SHOP_TAB_COUNT_MAX=3``entity_store``SHOP_GC_START_EX`(10) 解析 `TPacketGCShopStartEx{u32 owner_vid; u8 tab_count}` + `tab_count` × `{ShopTabHead, ShopItem[SHOP_HOST_ITEM_MAX_NUM=40]}`(对齐 `PythonNetworkStreamPhaseGame.cpp RecvShopSub_StartEx`)。`ShopEntry``u8 pos`(货架内槽位);`ShopTab{name, coin_type, items}` + `m_shop_tabs``SHOP_GC_START` 也改为按固定 40-槽数组填 `pos`(空槽计入,与参考 `SetItemData(j,...)` 一致);`SHOP_GC_END``m_shop_tabs``shop_items()` 保持是 `m_shop_tabs[0].items` 的镜像,旧 `get_shop_items()` 不动。`M2Client.get_shop()``{vid, open, tabs:[{name, coin_type, items:[{pos,vnum,price,count}]}]}``shop_ui.gd``tabs.size()>1` 时显示货架 `Button` 行,切货架重建列表;买位置 = `_active_tab * SHOP_SLOT_COUNT(40) + it.pos`1:1 对齐 `uishop.py GetIndexFromSlotPos` = `tabIdx * shop.SHOP_SLOT_COUNT + slotPos``shop_buy``u8 pos` 容得下 3×40)。`net_entity_test`:双货架合成包 → 断言名称 / coin_type / 槽位保留 / tab0 镜像 / END 清空;`p8_test`:多货架 UI + 切页 + `buy pos 5`tab0 slot5/ `buy pos 43`tab1 slot3 | `extension/src/net/{wire.h,entity_store.{h,cpp},m2_client.{h,cpp}}` · `project/ui/shop_ui.gd` · `extension/tests/net_entity_test.cpp` · `project/p8_test.gd` |
| **选魔石窗(样板第 2 例)** | ✅ `project/ui/select_item_ui.gd``UiScript`+`UiBuild``assets/uiscript/uiscript/selectitemwindow.py``ItemSlot` = 5×8 `grid_table``ui_build._slot_cells``x_count/start_index/x_step/y_step` 生成 `slot_0..39`)。填格逐字照 `assets/root/uiselectitem.py.RefreshSlot`:遍历背包 `range(INVENTORY_PAGE_SIZE*2=90)` 格,`_is_metin``proto.item(vnum).type == 10 = ITEM_TYPE_METIN`+ `_item_grade(vnum) <= 2`= `proto.item(vnum).name` **内部名**最后一位数字,非数字按 0,复刻 `PythonPlayerModule.GetItemGrade`),`slotPos > 54` 截断,按背包格号升序映射到选择窗格 `_slot_to_inv[slotPos]=invCell`。点格 → `client.script_select_item(invCell)``send_script_select_item` → `CGScriptSelectItem{CG_SCRIPT_SELECT_ITEM 0x0903, len, selection}`= `net.SendSelectItemPacket`+ 关窗;`ExitButton` / 标题栏关闭 → `script_select_item(0)``uiselectitem.Close`),`_sent` 标记避免选完再补发 0。**触发链 1:1**`quest_dialog.gd.parse_script` 新识别 EventManager `[SELECT_ITEM]` token → `has_select_item``_on_script` 发新 `select_item_requested` 信号(对应 `PythonEventManager EVENT_TYPE_SELECT_ITEM``interfacemodule.BINARY_OpenSelectItemWindow``wndItemSelect.Open()`);`game_scene``proto`/`item_list` 就绪后建 `select_item_ui` 并把该信号接到 `.open``select_item_ui_test.gd``[SELECT_ITEM]` token 解析 + 非 metin / grade>2 过滤 + 升序映射 + 点格发 `script_select_item(7)` + 关窗不补发 + 空手关窗发 `script_select_item(0)` | `project/ui/select_item_ui.gd` · `project/ui/quest_dialog.gd` · `project/game_scene.gd` · `project/select_item_ui_test.gd` |
| **`item_proto` / `mob_proto` 读取器** | ✅ `mtproto::load_proto` —— 外层 `MIPX`(item`[fourcc][ver=1][stride][elements][datasize]`) / `MMPT`(mob`[fourcc][elements][datasize]`);内层 `MCOZ` CLZO 容器 = **XChaCha20**(本 fork 把 TEA 换成 libsodiumkey=BLAKE2b(key16,"M2DevPackEncrypt")nonce=BLAKE2b(key16,"M2DevNonce")[:24])解密 + **LZO1X** 解压。两把硬编码 4-DWORD key`ItemManager.cpp` / `PythonNonPlayer.cpp`)。`ItemRecord`/`MobRecord` 解前导字段(`bSpecular`@234 → §2.7**`alValues[6]`@191** —— aLimits[2]×5 @166 + aApplies[3]×5 @176 后,`bSpecular`@234 锚定整链)。armor 的 `alValues[3]` = race `.msm` 的 body shape index(对齐客户端 `__ArmorVnumToShape` `SHAPE_VALUE_SLOT_INDEX=3`)—— 实测 "Monk Plate Armour" 11200-09 全 = shape 3specular 随强化 0→100 递增。`proto.item_mob` CTest 断言 11209 `values[3]==3` + `specular==100`。5748 itemsstride 236+ 1338 mobsstride 335 | `extension/src/proto/proto.{h,cpp}` |
| **`AssetResolver` 接包**(首版)| ✅ `mtpack::PackMount` —— 挂载多个 `.epk`,按虚拟路径(盘符 + 反斜杠 + 大小写不敏感 + `ymir work/` 后缀)resolve/read`metin2_patch_*` 包后挂覆盖。`mtpack::AssetSource` —— 散文件 + 包统一:`read()` 取字节;`to_path()` 把包内文件解到缓存目录一次(路径型调用方不用改)。散文件优先于包(dev overlay,对齐客户端 `pack/` 覆盖)。`pack.roundtrip` CTest 扩:2 包挂载 + 覆盖 + 虚拟路径 + 非 ymir 路径。⬜ 把 `Metin2World`/`Metin2Model` 等 ~6 处 `resolver->resolve()` 换成 `AssetSource::to_path()`(机械改动)| `extension/src/pack/{pack_mount,asset_source}.{h,cpp}` |
| **输入 & 游戏相机**(首版)| ✅ `game_camera.gd` = `GameCamera`:右键拖拽环绕(yaw/pitch clamp+ 滚轮缩放(3.5–20m)+ 目标平滑跟随 + 地形防穿。`player_controller.gd` = `PlayerController`:**点地移动**(射线求地表交点 → 走过去,沿途 `is_blocked``attr.atr` bit0)判可行走,贴墙磨蹭按实际位移切回 wait —— 客户端预测,无服务端纠正)+ WASD 覆盖 + **选目标**(射线取最近 pickable → `target_selected` 信号)+ `anim_state`/`moved` 信号。已换进 `world_demo.gd`(去掉 ~70 行临时相机/输入)。⬜ 相机撞建筑/树淡出(§8.2)、格子 A* 寻路、NPC 交互/拾取 | `project/{game_camera,player_controller}.gd` |
| **本地化**(首版)| ✅ `locale.gd` = `Locale`:加载 `locale/locale/<lang>/{locale_interface,locale_game,itemdesc,skilldesc}.txt``KEY\tVALUE`UTF-8)。`t(key, args)` —— `%` 格式化 + 缺失回 `<KEY>`(开发期可见);`set_lang()``apply_font()` 挂 CJK ttf。已接 `charselect``JOB_*` 职业名 / `SELECT_LEVEL` / `SELECT_SELECT`=Start / `SELECT_CREATE`+ `login``LOGIN_ID`/`LOGIN_PASSWORD`/`LOGIN_CONNECT`)。18 语言(拉丁/西里尔/希腊/阿拉伯,Godot 原生,无需打包字体;CJK 需外挂 ttf)| `project/locale.gd` |
| **声音**(首版)| ✅ `audio.gd` = `Audio` 节点:**BGM 交叉淡**2× `AudioStreamPlayer``bgm/*.mp3`+ **UI 音效池**8×,`**/sound/ui/*.wav`+ **3D 定位音效**`AudioStreamPlayer3D``**/sound/<rel>`)。路径解析走 11 个声音根 + `bgm/``.mss` 不用,Godot 原生解 mp3/wav/ogg)。已接:`charselect``characterselect` BGM + `<class>_select` 选人音;`login``loginok`/`loginfail``world_demo``Metin2AnimPlayer.motion_event``sound` 字段 → `play_at(model_pos)`(脚步/挥击)。⬜ 按地图 BGM(服务端 `GCMainCharacter.szBGMName`)、`.msenv` 环境音循环、战斗/怪物/NPC 语音 | `project/audio.gd` |
| 登录 / 选人界面 | 🟡 首版 | `project/{login,charselect,ui_kit}.gd` |
| 声音(`AudioLib`→Godot audio| 🟡 首版 | `project/audio.gd` |
| `item_proto` / `mob_proto` 读取器 | ✅ | `extension/src/proto/proto.{h,cpp}` |
| 本地化(`EterLocale`| ✅ 首版 | `project/locale.gd` |
| 输入 & 游戏相机 | 🟡 首版 | `project/{game_camera,player_controller}.gd` |
| `AssetResolver` 从包读取(接 eterpack| 🟡 | `extension/src/pack/{pack_mount,asset_source}.{h,cpp}` |
## 3c. C 轨(渲染打磨,PARITY-GAP 不卡 §0 门禁的项)
| 项 | 状态 | 位置 |
|---|---|---|
| §8.2 相机遮挡淡出 + §8.3 相机撞建筑防穿 | ✅ 首版 | `extension/src/metin2_world.cpp`(层 2 盒碰撞)+ `project/game_camera.gd` |
| §2.2 GPU 路径发型 | ✅ | `extension/src/metin2_model.{h,cpp}` |
| §2.10 角色 LOD 距离切换 | ✅ 首版 | `extension/src/metin2_model.{h,cpp}` |
| §3.5 地形 patch 裁剪 | ✅ 首版(拆 4×4 patch + 逐 patch 视锥剔除 + 远景剔除)| `extension/src/metin2_world.{h,cpp}` |
| §2.6 顶点色 | ✅ 排查=不适用(全资产 gr2 无顶点色成员;逐顶点色是地形雾运行时 VB + `.mse` colorfactor| — |
| §1.2 `.mse` EffectLib 端口 | ⬜(大工程)| — |
## 4. 下一步
1. 打通到 `.203` 的连通性(见上)。可达后立即能测:
- `./build/extension/net_probe 192.168.21.203 11000 admin 123456789` → 完整握手 + `CGLogin3``login_key`
- GDScript 里 `M2Client.connect_to_server(...)` → 一路到 `char_list` / `entered_game`
2. **`GCLoginSuccess3` 布局对真实字节校准**`net_probe` / `M2Client` 收到后 dump 原始字节,
核对 `TSimplePlayerInformation`(现假设 pack(1)、字段顺序照 `Packet.h:1161`)。size 不符会走
`login_failed` 兜底。
3. 游戏相位包解码:`GC_CHARACTER_ADD/DEL` / `GC_MOVE` / `GC_CHARACTER_UPDATE`
`NetworkActorManager` 端口 → 每实体一个 `Metin2Model`+`Metin2AnimPlayer`,用现有
`Metin2World` 当场景。里程碑「角色连上、站在地图里、别的实体在动」。
4. 输入(点地移动 → `CG_MOVE`)、聊天(`CG_CHAT`/`GC_CHAT` 接 HUD)。
## 4. 风险
- **协议版本漂移**`Packet.h` 结构体要和服务端 build 一致。冻结一对 client/server commit。
- **端到端测试阻塞**:目前握手端口 refused,走不到 `CGLogin3` 之前的验证。
- **移动端 libsodium**Android/iOS 要静态 vendor(本身可移植,非阻塞)。
- 自用范围可跳过:商城 / GM 工具 / 部分社交系统。**反作弊(`CG_HACK` / `ProcessCRC` /
`ProcessScanner`)已在 `CLIENT-GAP.md`「不做清单」标注 ❌ 不做**,除非服务端强制校验。
## 5. 移植面(客户端源码)
`EterBase/SecureCipher` · `EterLib/{NetStream,ControlPackets,NetPacketHeaderMap}` ·
`UserInterface/{Packet.h, AccountConnector, PythonNetworkStream, PythonNetworkStreamPhase*}` ·
`UserInterface/NetworkActorManager`(实体) · `GameLib`(玩法)。
+352
View File
@@ -0,0 +1,352 @@
# 客户端玩法开发计划
> 把 [`CLIENT-GAP.md`](./CLIENT-GAP.md) 的缺口排成可执行的阶段计划。
> 网络栈进度见 [`CLIENT-PORT.md`](./CLIENT-PORT.md),渲染见 [`PARITY-GAP.md`](./PARITY-GAP.md) / [`BACKLOG.md`](./BACKLOG.md)。
>
> 规模:S ≤ 3 天 · M ≈ 1 周 · L ≈ 2–3 周 · XL ≈ 1 月+
> 标记:🌐 需服务器端到端 · 💻 离线可做(mock server / 合成封包 / 假 client
> 更新日期:2026-08-30 · 自用,无发布门禁
> **P0P10 首版全部完成**`192.168.21.203` 已联通,`net_e2e` 端到端到 PHASE_GAME。里程碑见文末。
## 已就绪的地基(这些不重复做)
- net 栈:握手 / auth / login / select / game 相位,`M2Client` 编排 + 生命周期
- `EntityStore`:实体模型 + 插值 + 战斗 / 状态封包(points / vitals / damage / motion / stun / dead / target
- `NetWorld``net_world.gd`):实体→场景桥,占位胶囊 + HP 条 + 伤害飘字 + 死亡倒地
- `M2Client` GDScript 面:`move/attack/set_target/say` + `get_entities/get_entity/get_points/get_target` + 12 个信号
- 渲染节点:`Metin2Model`(骨骼 + crossfade + 武器挂点 + LOD + 换肤)、`Metin2World``Metin2AnimPlayer`
- 资产:`mtproto`item/mob_proto)、`mtpack`eterpack)、`locale.gd``audio.gd`
## 依赖关系
```mermaid
graph TD
P0["P0 闭环骨架 💻"] --> P1["P1 UI 工具层 💻"]
P0 --> P4["P4 战斗表现 🌐"]
P1 --> P2["P2 物品/背包/装备 🌐"]
P1 --> P3["P3 聊天 + TextTail 💻"]
P1 --> P8["P8 社交/商店/仓库 🌐"]
P5["P5 EffectLib 特效引擎 💻→🌐"] --> P4
P5 --> P6["P6 技能系统 🌐"]
P2 --> P6
P1 --> P6
P1 --> P7["P7 任务/NPC 🌐"]
P2 --> P9["P9 世界系统 🌐"]
P0 --> P10["P10 网络补全 🌐"]
```
P1UI 工具层)是 P2/P3/P6/P7/P8 的共同前置。P5(特效)可**任何时候并行起**
`.mse` 解析器纯离线)。
---
## P0 — 闭环骨架(M,💻)—— ✅ 离线部分完成,待服务器端到端
**目标**:现有骨架变成「点怪→走过去→挥→掉血→切目标」的可玩手感,无新系统。
| 任务 | 规模 | 状态 | 落点 |
|---|---|---|---|
| 输入接线 | S | ✅ | `net_play.gd``pc.moved``M2Client.move(FUNC_MOVE)`(节流 >1m / >0.2s),`anim_state("wait")``move(FUNC_WAIT)`(一次),`pc.target_selected``set_target(vid)`vid 取节点 `vid` meta |
| 本地预测 + 服务器校正 | S | ✅ | `net_play._on_net_moved`:收到自己 vid 的 `entity_moved` → 差 >6m 瞬移、>0.3m lerp 0.25,高度本地贴地不信服务器 z |
| HUD 数据绑定 | S | ✅ | `hud.gd``set_vitals/set_exp/set_level/set_target/clear_target``net_play``points_changed`/`vitals_changed`。血条 / 蓝条 / 经验细条 / 等级 label |
| 目标血条 | S | ✅ | `hud._build_target_panel`(顶部居中,名字 + 180px 血条);`net_play``target_info(vid,hp%)` + 目标 `vitals_changed` 刷新;目标死亡 / despawn → `clear_target` |
| 攻击动作驱动 | S | 🟡 | `net_play`:进 2.5m 距离按 `attack_period`(0.6s) 自动 `attack(0, vid)` + 面向目标。真模型动作切换靠 `net_world``set_model_factory` + `set_anim_state("attack")`P4 细化 motion index / 攻速) |
| 坐标 / 朝向换算 | S | ✅ | Godot 米 ↔ 服务器 cm`position_to_godot` 逆)、Godot yaw ↔ Metin2 罗盘度(`net_world` 逆)|
| net_world 去重主角节点 | S | ✅ | `net_world.set_local_vid(vid)`:本地玩家由 `player_controller` 的节点代表,`net_world` 不再画一个 |
| 场景组装 | S | ✅ | `game_scene.gd`(简版):`setup(client, assets_root, map_path?)``Metin2World`(load 失败则平地兜底)+ 占位玩家 + `GameCamera` + `PlayerController` + `NetWorld` + `HUD` + `NetPlay` + `Audio` + `AppLifecycle``entity_main_set` → 主角定位出生点。`login.gd``entered_game` → 隐藏登录层 + 实例化(`--auto` 自动选槽 0)。`set_player_model()` / `set_entity_model_factory()` 留给 P2 |
**已验证**
- `netplay_test.gd` —— move 带正确 server cm + 节流、FUNC_WAIT 单发、set_target、攻击距离内自动 attack + 节流、points→HUD、远程校正瞬移、target_info→HUD、死亡清目标。
- `gamescene_test.gd` —— 装配 + 子系统接线 + 实体镜像(主角不重复画)+ 出生点定位(无地图兜底)。
- 项目导入无错,`netbridge_test` 仍过。
**未完成(仅剩服务器相关)**
- **端到端**:走真 `GC_MOVE`/`GC_PLAYER_POINTS`/`GC_TARGET_INFO``192.168.21.203` 可达。net 栈就绪,改 IP 即测。
- 攻击动作用真 `Metin2Model``set_player_model` / `model_factory` 把 race→gr2,P2 装备系统会带出来)。
- `login → charselect → game` 正式串场(现在 `--auto` 直接选槽 0`charselect.gd` 已存在,接一下即可,属 P10)。
## P1 — UI 工具层(L,💻)—— 🟡 核心完成(装载器打通全量语料)
**目标**:一层 Godot `Control` 封装对齐 `EterPythonLib`,让 P2+ 的窗口可批量搬。
| 任务 | 规模 | 状态 | 落点 |
|---|---|---|---|
| **uiscript 装载器** | M | ✅ | `ui/uiscript.gd`:解析原始 `assets/uiscript/*.py``window={...}` dict(子集解释器:str/int/float、NAME、`uiScriptLocale.X`、算术 `+ - * /``%` 格式化、相邻字符串拼接、`SCREEN_WIDTH/HEIGHT` + 文件顶层常量、`#`/`##` 注释)。防跑飞 guard。**实测 80/80 个真实 uiscript 文件解析 + 构建全绿** |
| 控件封装 | M | 🟡 | `ui/ui_build.gd`dict → Control 树,覆盖 ~20 种 typewindow/board/thinboard/board_with_titlebar/titlebar/text/button/toggle_button/radio_button/image/expanded_image/editline/line/listbox/gauge/grid_table/slotbar/sliderbar/scrollbar+ `horizontal_align`/`vertical_align`/`text_color`(0xAARRGGBB)/按钮 default/over/down 贴图。`ui/ui_assets.gd``.sub`/`.tga`/`.png`/`.jpg``.dds` 暂不支持→降级主题)。九宫格复用 `ui_kit.board`。⬜ 少见 typeani_image 动画、mark、grid_table 内容填充、真 scrollbar 行为)按窗口逐个细化 |
| 窗口管理器 | S | ✅ | `ui/ui_manager.gd``CanvasLayer` layer=10):窗口栈、`open/close/close_top/top`、ESC 关顶层、modal 变暗层、标题栏(`titlebar` / `board_with_titlebar` / meta `is_titlebar`)拖动、点击置顶。`open_script(path, assets)` 一步解析+构建+开 |
| 通用弹窗 | S | ✅ | `ui/dialogs.gd``confirm(text,on_ok,on_cancel)` / `alert(text)` / `input(prompt,on_submit,default)`,走 `UiManager.open(modal=true)` |
| 拖拽层(物品图标跟光标 + 落点命中 slot) | S | ⬜ | 归 P2(背包才用到)|
**已验证**`ui_test.gd` —— 解析器(算术 710、hex 色、locale 解析、常量、坏输入→{})、
构建器(节点类型 / 尺寸 / 位置 / center 对齐数学 / board 标题)、窗口管理器(栈 / 置顶 /
close_top / modal dim)、弹窗(confirm 打开 + modal + on_ok + 关闭)、真实文件
`popupdialog.py` name/children/.sub 按钮 + 构建)。全过。全量语料 sweep 80/80。
## P2 — 物品 / 背包 / 装备(L,🌐 解析+UI 💻)—— 🟢 主体完成(离线部分),端到端待服务器
**目标**:捡东西、开背包、穿脱装备(穿装备换模型 → 解锁 `PARITY §2.7` 高光 / `§2.3` 时装)。
| 任务 | 规模 | 状态 | 落点 |
|---|---|---|---|
| `GC_ITEM_*` 封包 + 模型 | S | ✅ | `wire.h``ItemPos`(3) / `ItemAttr`(3) + `GC_ITEM_SET`(54) / `DEL`(7) / `UPDATE`(41) / `USE`(19) / `GROUND_ADD`(24) / `GROUND_DEL`(8) / `GET`(75) + `CG_ITEM_MOVE`(11) / `USE`(7) / `DROP`(11) / `PICKUP`(8),全 `static_assert``EWindows` 常量。`EntityStore``m_inventory[90]` / `m_equipment[11]` / `m_ground` map + `apply()` 全部 7 包 + `drain_inv/drain_ground/drain_item_events``game_client.h` 路由 + `send_item_move/use/drop/pickup` |
| `Metin2Proto` 节点 | S | ✅ | 新 GDExtension 类:`load_item_proto/load_mob_proto` → vnum→index map`item(vnum)` / `mob(vnum)` → Dictname / locale_name / type / sub_type / wear_flags / prices / specular …)。实测载入 5748 真实 item,`item(1)`=Yang、`item(19)`=Sword+9 |
| `M2Client` 物品面 | S | ✅ | `get_inventory()` / `get_equipment()` / `get_item(win,cell)` / `get_ground_items()`(坐标转 Godot 米)+ `move_item/use_item/drop_item/pickup_item`;信号 `inventory_changed(win,cell)` / `ground_item_added(Dict)` / `ground_item_removed(vid)` / `item_picked_up(vnum,count,from)` / `item_used(vnum)` |
| 背包 / 装备窗 | M | ✅ | `ui/inventory_ui.gd``ui_manager.open_script("inventorywindow.py")`P1 装载器)→ 索引格子 → `M2Client.get_inventory/get_equipment` 填(icon `icon/item/<vnum>.tga` 或名字文本 + 数量 + tooltip 用 `Metin2Proto` 名字)→ `inventory_changed` 刷新 → 右键 `use_item` / 拖到别格 `move_item``ui/ui_build.gd` 加 slot-cell 构建(`grid_table`/`slot`/`slotbar``start_index`/`x_count`/`y_count`/`x_step`/`y_step` 网格 + 显式 `"slot"` 元组)。接进 `game_scene.gd`(I 键开关)。UI 格子编号:0..89 背包 / 90+ 装备(wear=idx-90) |
| item_list.txt 读取 | S | ✅ | `ui/item_list.gd`:解析 `locale/locale/common/item_list.txt`TSVvnum→type/icon/model)。用于背包图标(替代 vnum 猜路径)+ 武器模型解析 |
| 本地玩家真模型 | M | ✅ | `ui/player_view.gd`PlayerViewNode3D):`build(assets, race)` —— race 0..7 → class + 性别 → `pc(2)/<class>/<class>_novice.gr2` 身体 + `hair/` + `Metin2AnimPlayer``set_anim_state("wait/walk/run/attack/dead")``general/<s>.msa`crossfade)。`_set` 转发 `weapon_gr2`/`gr2_path`/`hair_*` 给内部 `Metin2Model``game_scene._on_main_set` 首次拿到 race 时 `set_player_model(pv)` + 接 `pc.anim_state` |
| 穿脱 → 换模型 | M | ✅ | `ui/equip_model.gd``inventory_changed(EQUIPMENT)``get_equipment()`**WEAR_WEAPON(4)** → `item_list.model(vnum)``player.set("weapon_gr2", …)`**WEAR_SHIELD(10)** → `shield_gr2``Metin2Model` 新增第二个刚体挂点,默认骨 `Bip01 L Hand` —— PC 骨架没 `equip_left_hand`,共用 `_load_attach` 抽出的加载器);**WEAR_BODY(0)** → `ui/race_spec.gd``<race>.msm``ShapeData`/`HairData` 组 → model + SourceSkin/TargetSkin)→ `armor_shape_of(vnum)`(默认 =vnum,可覆写)→ `gr2_path` 换整模 + 换肤;**WEAR_HEAD(1)** 有模型的头盔 → 覆盖 `hair_gr2` 槽(Metin2 约定:头防替发型),拆下回 `parts[CHR_EQUIPPART_HAIR]``race_spec.hair(idx)`。头发也随 `entity_info`GC_CHARACTER_UPDATE 改 parts)刷新。`equip_model_test`:武器 / 盾装拆、`armor_model_map` 注入、头盔覆盖 hair 槽 / 拆回 parts 发型;`race_spec_test`:真 `warrior.msm` 26 shape、shape 9 → `warrior_cheongrin.gr2` + 换肤 |
| 地面掉落物 + 拾取 | S | ✅ | `ui/ground_items.gd``ground_item_added(Dict)` → 旋转小方块 + billboard 名条(`Metin2Proto` locale 名);`_process` 近距离名条变绿;`try_pickup()`Z 键)→ 范围内最近的 → `pickup_item(vid)``ground_item_removed` → free。接进 `game_scene.gd` |
| belt inventory / 快捷栏 | S | ⬜ | `beltinventorywindow.py` `taskbar.py` |
**已验证**8 个 GDScript 测试套件全绿 + `ctest 9/9` + uiscript sweep 80/80 + iOS 编过):
`netbridge_test`(物品面 + `Metin2Proto` 5748 item)·
`inventory_ui_test`(真 `inventorywindow.py` 开窗 / 填充 / tooltip / 刷新 / use / move)·
`equip_model_test``item_list.txt` + 武器 vnum→真实 gr2)·
`p2b_test`(地面掉落 spawn/名条/拾取 + PlayerView 真 warrior 模型 + `set_anim_state`)·
`race_spec_test``warrior.msm` 解析 26 shape + 装 shape 9→真实 cheongrin.gr2 + 换肤)。
**未完成**belt / 快捷栏、`.epk` 内 icon(需 `AssetSource` → GDScript)、
拖动图标跟手视觉、`armor_shape_of` 用真 `item_proto` value[3](现 =vnum)、
**端到端**(真服务器 `192.168.21.203`)。
## P3 — 聊天 + TextTail(M,💻)—— 🟢 主体完成(离线部分)
**目标**:完整聊天窗 + 头顶信息。
| 任务 | 规模 | 状态 | 落点 |
|---|---|---|---|
| `GC_WHISPER` / `CG_WHISPER` | S | ✅ | `wire.h``GCWhisper`(70) / `CGWhisper`(69) + `EChatType` / `WHISPER_TYPE_*` 常量,`static_assert``EntityStore::apply(GC_WHISPER)``ChatMsg{type=WHISPER, from, sub, text}``ChatMsg``from`/`sub` 字段)。`game_client.h` 路由 + `send_whisper(name,text)``M2Client``whisper(to,text)` 方法 + `whisper_received(sub,from,text)` 信号(与方法不同名,避 GDScript 冲突)|
| 聊天窗 | M | ✅ | `ui/chat_ui.gd`:底部面板,4 标签(全部 / 私聊 / 系统 / 战斗)+ `RichTextLabel` 滚动 + `LineEdit` 输入。前缀 `/w 名 话``whisper` / `/g`→GUILD / `/p`→PARTY / `/s`→SHOUT / 其它→TALKING。消费 `chat`(按 type 分标签,名字取 `get_entity(vid).name`/ `whisper_received` / `item_picked_up`。每类型配色(bbcode)。接进 `game_scene.gd`Enter 聚焦输入) |
| 聊天气泡 | S | ✅ | `net_world.gd._bubble`:普通 / 队伍 / 公会 / 喊话 → 实体头顶 `Label3D`(3s + 1s 淡出);本地玩家 vid → `main_bubble` 信号 → `game_scene._player_bubble``player` 头顶 |
| 头顶信息完善 | S | 🟡 | 名字 + HP 条 + 伤害飘字已有(P2);⬜ 称号 / 公会名 / 目标高亮框 |
| 系统消息 / 战斗日志 | S | 🟡 | 拾取 → 系统 + 战斗标签;⬜ `GC_DAMAGE_INFO` / 经验 / 金钱变化的文本行(signal 已到位,接一下即可) |
**已验证**`net.entity_store` 扩展(whisper 包解析);`chat_test.gd`(4 标签路由:talking→全部带名 / info→系统+全部 / whisper→私聊+全部 / 系统 whisper→`[系统]` / 拾取→系统+战斗;前缀解析 `/w` `/g` `/s` + 本地回显;气泡:实体头顶节点 + 本地 vid→`main_bubble`)。9 个 GDScript 测试套件全绿,ctest 9/9iOS 编过。
## P4 — 战斗表现完善(L,🌐 表现 💻)—— 🟡 大部分完成(离线部分)
**目标**:战斗「有肉感」——拖尾、硬直、连段、状态、死亡复活、镜头。
| 任务 | 规模 | 状态 | 落点 |
|---|---|---|---|
| affect 封包 + 图标条 | M | ✅ | `wire.h``AffectElement`(21) / `GCAffectAdd`(25) / `GCAffectRemove`(9)`static_assert``EntityStore``m_affects` map + `drain_affects()` + `affects()``M2Client``get_affects()` + `affect_added(Dict)` / `affect_removed(type)` 信号。`hud.set_affects(array)` —— 顶部图标条(色块 by type + tooltip 显 type/point/value/duration)。接进 `game_scene` |
| 受击硬直 / 锁输入 | S | ✅ | `net_play._on_damage`:自己是 victim 且非 `DAMAGE_DODGE``_hitstun_until = now + 0.32s``is_stunned()` 期间 `_process` 不发攻击 + `pc.frozen = true``player_controller._process` 见 frozen 即 `anim_state("wait")` 早退)+ `player_view.set_anim_state("damage")` |
| combo 连段 | S | ✅ | `net_play._process` 攻击:`COMBO_WINDOW`(0.9s) 内连续攻击 → `_combo` 0→1→2 循环,`attack(attack_motion + _combo, vid)` + `set_anim_state("combo"/"attack")` |
| 相机抖动 | S | ✅ | `game_camera.shake(strength, decay)` —— `_process` 里加随机偏移,指数衰减。`net_play._on_damage` 命中 0.04m / 暴击 0.08m |
| 死亡 / 复活流程 | S | ✅ | `ui/death_ui.gd``entity_dead(main)``phase_changed("dead")` → 灰屏 + 窗(在此复活 / 回城复活 → `say(0, "/restart_here"/"/restart_town")`,同客户端走 quest 命令);`vitals_changed` hp>0 → 自动关。接进 `game_scene` |
| 相机锁定目标视角 | S | ⬜ | `game_camera` 锁定 target 时 yaw 跟随;低优先 |
| 武器挥砍拖尾 | S | ⬜ | 需 `Metin2Model` 暴露每帧武器骨骼世界变换 → `ImmediateMesh` 条带。纯视觉,延后 |
**已验证**`net.entity_store` 扩展(affect add/remove);`combat_fx_test.gd`(受击→硬直+抖屏+damage 动作,闪避不硬直,他人受击不影响;combo 0→1→2 循环;`hud.set_affects`;死亡窗弹出 + 在此复活→`say("/restart_here")` + 复活自动关)。10 个 GDScript 测试套件全绿,ctest 9/9iOS 编过。
## P5 — EffectLib 特效引擎(XL,💻 起 → 🌐 用)⏭ 独立立项 —— 🟡 首版打通
**目标**`.mse` 特效脚本 → Godot。见 `PARITY-GAP.md §1.2`
| 任务 | 规模 | 状态 | 落点 |
|---|---|---|---|
| `.mse` 解析器 | M | ✅ | `fx/mse.gd`CTextFileLoader 花括号树 + `List` 行表解析。`parse_file``{bsphere_r, bsphere_pos, particles:[{start_time, position, emitter, particle}], meshes:[...]}`。**实测真 `geompung_3_sword.mse` → 6 particle**。`.mse` 全资产集只有 3 种 groupParticle(514)/Mesh(22)/MeshElement,无 SimpleLight |
| Particle → GPUParticles3D | M | ✅ | `fx/effect_player.gd`:每 Particle 组 → `GPUParticles3D` + `ParticleProcessMaterial`。映射:`EmitterShape`(0点/3球+`EmittingRadius`)、`MaxEmissionCount`→amount、`TimeEventLifeTime`→lifetime、`TimeEventEmittingVelocity`→速度、`TimeEventGravity`→重力、`TimeEventSizeX`→scale、`TimeEventScaleX``scale_curve`(Curve)、`TimeEventColorRGB`+`TimeEventAlpha``color`+`color_ramp`(Gradient)、`RotationSpeed`→angular_velocity。绘制:`QuadMesh` + `StandardMaterial3D``BillboardType`→billboard_mode、`Src/DestBlendType`→blend_modedst=2→ADD)、程序化径向渐变代 `.dds`|
| EffectMesh / Light / 时间轴 | M | 🟡 | 时间轴:每组按 `StartTime` 延迟 `create_timer` 起 emitting`one_shot` 到寿命自动 `queue_free`。Mesh 组占位小方块(`.mde` 解码器待做)。Light:本资产集未用 |
| 挂点系统 | S | 🟡 | `spawn(name, parent_node)` 挂到任意节点下(跟随变换);`spawn_at(name, world, pos)` 世界定点。绑骨骼需 `Metin2Model` 暴露骨骼节点,待做 |
| 特效索引 + 服务器触发 | S | ✅ | `fx/effect_registry.gd`:名字 / 相对路径 / 盘符路径 → `.mse`(散包扫 + 裸名递归找 depth 7)+ 解析缓存。`wire.h` `GC_SPECIAL_EFFECT`(9) / `GC_SPECIFIC_EFFECT`(136) + `EntityStore::drain_effect_cues()` + `M2Client.effect_cue(vid, name, special)` 信号 → `game_scene` 在实体上 `fx.spawn(name)` |
**已验证**`net.entity_store` 扩展(specific/special effect cue 解析);`fx_test.gd``.mse` 解析:bsphere / 2 particle / List 行表 / 引号枚举 / 嵌套 groupEffectPlayer2 GPUParticles3D + amount + 球形发射 + 加法混合 + start_time meta + play()→emittingregistry:解析真 `.mse` 6 particle + spawn 挂载)。11 个 GDScript 测试套件全绿,ctest 9/9iOS 编过。
**未完成**`.dds` 粒子纹理(GDScript 不解 DDS,需暴露 `Metin2Image.load_dds` 静态方法)、`.mde` mesh 解码、SimpleLight(本资产集未用)、绑骨骼挂点、`GC_SPECIAL_EFFECT` 的 id→路径表、时间轴的 `MovingType` 位置动画、TexAni 帧动画。
## P6 — 技能系统(L,🌐)—— 🟢 主体完成(离线部分)
依赖 P1(技能窗)+ P2(技能书物品)+ P5(技能特效)。
| 任务 | 规模 | 状态 | 落点 |
|---|---|---|---|
| 技能表 | S | ✅ | `ui/skill_table.gd`:解析 `skilldesc.txt`(**列偏移修正** —— 0-idxid0 job1 name2-4 desc5-8 (9空) **attrs10** weapon11 **motion_name12** **motion_idx13** grades14;旧代码整体差 1 列,`motion_idx` 一直是 0)。`entry`/`name_of`/`motion_idx_of`/`is_attack`/`is_passive`/`is_toggle`/`can_level_up`/`category_of`/`for_category``for_job` = 别名)。JOB 列即分类:WARRIOR/ASSASSIN/SURA/SHAMAN(主动) · SUPPORT(辅助/被动) · HORSE · GUILD |
| 技能封包 | S | ✅ | `wire.h``GCSkillLevel`(259flat u8[255]) / `GCSkillCooltimeEnd`(5) / `GCCreateFly`(13)`static_assert``EntityStore``m_skills[255]` + `skills_dirty()` + `drain_cooldown_ends()` + `drain_fly_cues()``M2Client``get_skills()`[{id,level}]+ `skills_changed` / `skill_cooldown_end(skill)` / `fly_cue(type,start,end)` 信号 |
| 技能窗 + 加点 | M | ✅ | `ui/skill_ui.gd`K 键):**三个分类页** `[主动][辅助][坐骑]`(→ `for_category(job/SUPPORT/HORSE)`)。每行 = 名 + `Lv X`(+ `M`/`G`/`P``master_type`) + `[被动]`/`[切换]` 标签 + `[]``is_passive``CANNOT_LEVEL_UP` 时无 +)。```M2Client.skill_up(id)`。点名 → `drag_skill_id``skills_changed` 刷新等级 + master |
| 快捷栏施放 + 冷却 | S | ✅ | `ui/quickbar.gd`(数字键 1-8):`assign(slot, "skill"/"item", id)`。技能 → `M2Client.cast_skill(motion_idx, heading, x, y)` + 本地 2s 冷却占位;`GC_SKILL_COOLTIME_END` 提前解锁。冷却遮罩自上而下扫。`restore_from_server()``get_quickslots()` 恢复。施放时 emit `skill_activated(id)``game_scene` 播技能特效 |
| 技能特效表 | S | ✅ | `fx/skill_fx.gd``skill_effect_name(id, master)` = `<skilldesc.motion_name>_<clamp(2+master,2,4)>``PC/ymir work/pc/<cls>/effect/*.mse``_blow`/`_head`/… 子特效存在就一起播);`GC_SPECIAL_EFFECT` 内建 id → `SPECIAL_FX` 表(`drugup_red/blue/green``firecracker_1``fail` …)。`game_scene``effect_cue``special` 分支 + `quickbar.skill_activated` |
| 飞行道具 | S | 🟡 | `net_world._on_fly``GC_CREATE_FLY` → 从 start 实体飞向 end 实体的小发光球(tween,按距离算时长)。⬜ 真弹道 mesh / 命中特效 / `GC_FLY_TARGETING` 追踪 |
**已验证**`net.entity_store` 扩展(skill level / cooltime end / fly cue);`skill_test.gd`(真 `skilldesc.txt` >30 skill + WARRIOR 列 + skill 1 job/name/motion_idxskill 窗行数 = 职业技能数 + +→`skill_up(1)` + Lv 5 显示;quickbar assign→activate→`cast_skill` 用正确 motion_idx + 冷却中不重发 + `cooldown_end` 后可再发)。12 个 GDScript 测试套件全绿,ctest 9/9iOS 编过。
**未完成**:真弹道、加点等级公式 / 消耗校验(服务器管)。
被动 / 支援技能树 ✅(skilldesc 列偏移修 + skill_ui 三分类页 + `is_passive`/`master_type` 显示)。
技能特效表 ✅:`fx/skill_fx.gd` —— skill id + master → `<motion_name>_<2+master夹2..4>``skilldesc` col13 的
motion_name 就是特效基名,上一轮列偏移修好后自然接上);子特效 `_blow`/`_head`/`_foot`/`_yong` 存在就一起播。
`GC_SPECIAL_EFFECT` 内建 id → `SPECIAL_FX` 小表(`SE_HPUP_RED`=1→`drugup_red``SE_AUTO_HPUP`=19、
`SE_FAIL`=12→`fail` …)。`quickbar.skill_activated` 信号 → `game_scene` 播;`effect_cue``special` 分支
接上。实测真资产:skill 1/5/17/31/92/93 + SE 1/2/3/9/12/19/20 全部 resolve 到真 `.mse`
## P7 — 任务 / NPCL,🌐)
| 任务 | 规模 | 状态 | 落点 |
|---|---|---|---|
| NPC 交互 | S | ✅ | `wire.h``CGOnClick`(8)、`CG_ON_CLICK`(0x0A02)。`M2Client.click_npc(vid)``send_on_click``net_play._on_pick``ch_type ∈ {1,3,4}`NPC/石头/warp)→ `click_npc(vid)` 而非攻击 |
| 对话框 | 🟡 | 🟡 | `wire.h``GCScript`(7`skin`+`src_size`)、`GCQuestConfirm`(77)、`CGScriptAnswer`(5) / `CGScriptButton`(8) / `CGQuestInputString`(69) / `CGQuestConfirm`(9)`static_assert``EntityStore``ScriptCue{skin,text}` / `ConfirmCue{msg,timeout,pid}` + `drain_scripts()` / `drain_confirms()``ui/quest_dialog.gd``parse_script``[ENTER]`/`[CLEAR]`/`[NEXT]`/`[DONE]`/`[QUESTION arg(..)]`/`[LETTER]` → 文本 + 选项 + 继续钮;选项 → `script_answer(idx)`,无选项 → `script_answer(255)``GC_QUEST_CONFIRM` → 接受/拒绝 → `quest_confirm(yes,pid)`。⬜ `[INPUT]` 输入框、`SCRIPT_SELECT_ITEM` 选道具、左右立绘 |
| 任务日志 + 指引 | 🟡 | 🟡 | `wire.h``GCQuestInfo`(7 + flag 驱动变长尾)、`QUEST_SEND_*` flag 枚举。`EntityStore``QuestInfo{index,flag,begin,title,clock_*,counter_*,icon}` + `m_quests` map + `drain_quest_changes()` / `quest(idx)` / `quest_indices()``ui/quest_log.gd`J 键):列 `◆ title` + `counter_name: value` + `clock_name: value``quest_info` 信号刷新。⬜ 地图 / 世界箭头(`TARGET_CREATE` 位置目标)、`[QUESTBUTTON]``script_button` |
| quest 脚本执行 | — | ✅(选型②) | 采纳推荐方案:**客户端纯渲染 `GC_SCRIPT`,quest 逻辑全在服务器**。不内嵌 Python 解释器 |
**已验证**`net.entity_store` 扩展(`GC_SCRIPT``[QUESTION]``ScriptCue``GC_QUEST_CONFIRM``ConfirmCue``GC_QUEST_INFO` 带 TITLE+COUNTER flag → `QuestInfo` 变长尾解析,`length` 字段回填);`quest_test.gd``parse_script`:纯文本保留 / `[ENTER]`→换行 / `[QUESTION]`→2 选项 / `[DONE]`→继续钮;`script_dialog` 信号 → 弹窗 + 3 选项钮 + 选 B → `script_answer(1)` + 关窗;无选项 → 继续 → `script_answer(255)``quest_confirm_ask` → 接受 → `quest_confirm(true,999)`quest_log 只显示有标题的 quest + `quest_info` 刷新不炸)。13 个 GDScript 测试套件全绿,ctest 9/9iOS 编过。
**未完成**`[INPUT]` 文本输入 UI`CGQuestInputString` 封包已备)、`SCRIPT_SELECT_ITEM` 选道具、NPC 对话左右立绘、`[QUESTBUTTON]``script_button`、地图 / 世界任务箭头(`TARGET_CREATE_TYPE_LOCATION`)、端到端(见 `CLIENT-PORT.md` §3:握手 + 认证已联通,进游戏串场归 P10)。
## P8 — 社交 / 商店 / 仓库(XL,🌐)
依赖 P1。窗口多,首版覆盖组队 / 好友 / 商店 / 交易 / 仓库五件;公会 / 精炼 / 私人商店留后续(拍卖行 m2dev-client 无)。
| 子系统 | 规模 | 状态 | 落点 |
|---|---|---|---|
| 组队 | M | ✅ | `wire.h``GCPartyInvite/Add/Update/Remove/Link/Unlink/Parameter`(0x0710-16) + `CGPartyInvite/InviteAnswer/Remove/Parameter`(0x0701-06)`static_assert``EntityStore``m_party`(pid→`PartyMember{vid,name,state,hp_pct,affects[7]}`) + `party_dirty()` + `drain_party_invites()` + `party_distribute_mode()``M2Client``party_invite/party_answer/party_leave/party_set_distribute` + `get_party()` + `party_changed`/`party_invite_ask(leader_pid)` 信号。`ui/party_ui.gd`:左侧常驻队员面板(名字 + ★队长 + HP 条),收邀请 → `Dialogs.confirm``party_answer` |
| 私聊 / 好友 | M | ✅ | `wire.h``GC_MESSENGER`(0x0741) / `CG_MESSENGER`(0x0740) sub-header 包(`GCSubHeader`/`CGSubHeader`+ `MESSENGER_GC/CG_*` 子头枚举。`EntityStore``m_friends`(name→`Friend{online}`) + `friends_dirty()`,解 `LIST``{u8 connected,u8 len,name}` 循环)/ `LOGIN`/`LOGOUT`/`REMOVE_FRIEND``M2Client``add_friend/remove_friend``ADD_BY_NAME`/`REMOVE` 变长包)+ `get_friends()` + `friends_changed``ui/friend_ui.gd`(O 键):在线点 + 名字 + [×],点名 → `whisper_to``chat.start_whisper("/w 名 ")` |
| NPC 商店 | M | 🟡 | `wire.h``GC_SHOP`(0x0810) / `CG_SHOP`(0x0801) sub-header + `SHOP_GC/CG_*` 子头 + `ShopItem`(43`TShopItemData`)。`EntityStore``SHOP_GC_START``u32 owner_vid` + `ShopItem[]`)→ `m_shop_items` + `m_shop_open`/`m_shop_vid``END` 清;错误 → `drain_shop_errors()``M2Client``shop_buy(pos,cnt)``BUY` = `[head][u8 cnt][u8 pos]`/ `shop_sell(cell,cnt)``SELL2`/ `shop_close` + `get_shop_items()` + `shop_opened(vid)`/`shop_closed`/`shop_error(kind)``ui/shop_ui.gd`:名字 + 价格 + [买],背包右键 → `sell(cell)`,错误红字。⬜ `START_EX` 多标签商店、图标、批量 |
| 交易 | M | 🟡 | `wire.h``GC_EXCHANGE`/`CG_EXCHANGE`(0x051C/0x0508) + `EXCHANGE_SUB_*`START/ITEM_ADD/ITEM_DEL/ELK_ADD/ACCEPT/CANCELGC 侧 END/ALREADY/LESS_ELK+ `GCExchange`(50`is_me`+`arg1/2/3`+sockets+attrs)/`CGExchange`(13)。`EntityStore``ExchangeState{active,partner_vid,self/peer_items[12],self/peer_gold,self/peer_accept}` + `exchange_dirty()``M2Client``exchange_start/add_item/add_gold/accept/cancel` + `get_exchange()` + `exchange_changed``ui/exchange_ui.gd`:我方 / 对方两栏 + 金币 + [接受]/[取消],背包右键 → `offer(win,cell)`display pos 0..11 轮转)。⬜ 道具图标格、拒绝时回滚提示 |
| 仓库 | M | 🟡 | `wire.h``GC_SAFEBOX_SET/DEL`(0x0830-31body 复用 `GCItemSet`/`GCItemDel`) / `SIZE`(0x0833) / `MONEY_CHANGE`(0x0834) / `WRONG_PASSWORD`(0x0832)`CG_SAFEBOX_CHECKIN/CHECKOUT`(0x0820-21`{u8 safe_pos, ItemPos inv}`) / `ITEM_MOVE`(0x0822,复用 `CGItemMove`)。`EntityStore``m_safebox[135]` + `m_safebox_size/gold/open` + `safebox_dirty()``M2Client``safebox_checkin/checkout/move` + `get_safebox_items()`/`get_safebox_size()`/`get_safebox_gold()` + `safebox_changed``ui/safebox_ui.gd`:金币 + 道具行 + [取出],背包右键 → `deposit(win,cell)`(自动挑空位)。⬜ 密码框、商城页(`MALL_*`)、拖拽网格 |
| 公会 | L | 🟡 | `wire.h``GC_GUILD`(0x0730) sub-header + `GUILD_GC_*`/`GUILD_CG_*` 枚举 + `GuildInfoBody`(35)/`GuildSubMember`(13`name_flag` 后跟 name[65])/`GuildSubGrade`(10)`static_assert``EntityStore``GuildState` + `m_guild_members` map + `m_guild_grades[16]` + `guild_dirty()`,解 INFO/LIST/GRADE/GRADE_NAME/CHANGE_EXP/MONEY_CHANGE。`M2Client``get_guild`/`get_guild_members`/`get_guild_grades` + `guild_add_member`/`guild_remove_member`/`guild_offer`/`guild_answer_make` + `guild_changed` 信号。`ui/guild_ui.gd`(G 键):信息行 + 成员列表(按 grade 排序,grade 名解析,★官员)+ 16×12 会徽图(放大 3×,`NEAREST`)。**真机实测**`[GM-TEAM]` lvl 20 members 1/70。**会徽下载(独立连接)**:`wire.h` `GC_MARK_IDXLIST`(0x0C11)/`GC_MARK_BLOCK`(0x0C10)/`GC_MARK_UPDATE`(0x0B15) + `CG_MARK_LOGIN`(0x0C01)/`CG_MARK_IDXLIST`(0x0C04)/`CG_MARK_CRCLIST`(0x0C02)`GC_MARK_*` body 用 u32 `buf_size` 而非 u16 帧长。`NetStream``on_cipher_active()` + `on_raw()`(子类自定义分帧)钩子。`mark_image.{h,cpp}``MarkImageSet`guild_id→mark_id、LZO1X 解压 64×48 块拼进 512×512、`rect_of`/`mark_pixels`+ `parse_mark_idxlist`/`parse_mark_block``mark_client.h``MarkClient : NetStream`KX→`CG_MARK_LOGIN{handle,random_key}``CG_MARK_IDXLIST`→逐图 `CG_MARK_CRCLIST`(全 0 CRC = 整图)→`GC_MARK_BLOCK`)。`GameClient``mark_handle`/`mark_random_key`(来自 `GC_LOGIN_SUCCESS3/4`+ per-slot `guild_id`/`guild_name` + `drain_mark_updates()``M2Client``download_guild_marks(host,port)` / `get_guild_mark(gid)` / `get_guild_mark_image(gid)``Image` + `guild_marks_ready` / `guild_mark_updated` 信号。`serverinfo.gd` 第 8 列 `mark_port`0=跳过);`app_flow` 进游戏时按配置连、`guild_mark_updated` 1s 冷却重连。**公会战 + 公会技能页**:`wire.h` `GUILD_GC_SKILL_INFO`(12) `GuildSkillInfoBody`(17=`{u8 point, u8 lvl[12], u16 gp, u16 max_gp}`)、`GUILD_GC_WAR`(15) `GCGuildWar`(10)、`GUILD_GC_GUILD_WAR_LIST/END_LIST`(17/18) `GuildWarPair`(8)、`GUILD_GC_WAR_POINT`(19) `GuildWarPoint`(12)、`GUILD_GC_NAME`(16) `GuildNameEntry`(16,无 NUL)`EGuildWarState` 枚举;`GUILD_CG_USE_SKILL`(9)。`EntityStore``GuildSkillState` / `GuildWarStatus` / `m_guild_wars` / `m_guild_names` map + `guild_skill_dirty()`/`guild_war_dirty()` + `drain_guild_war_events()`/`drain_guild_war_scores()``GameClient.send_guild_use_skill(vnum, target_vid)``M2Client``get_guild_skill`/`get_guild_wars`/`get_guild_war`/`get_guild_name` + `use_guild_skill(vnum, target)` + `declare_guild_war(name)`(发 `/war <name>` 聊天命令)+ `guild_skill_changed`/`guild_war_changed`/`guild_war_event`/`guild_war_point` 信号。`ui/guild_ui.gd` 改三页 Tab(成员 / 技能 / 公会战):技能页显示技能点 + 公会点 + 12 技能等级(名字取 `skill_table.for_category("GUILD")`)+ 已学技能「施放」按钮;公会战页显示当前战状态 + 进行中 GvG 列表(我方标记)+ 宣战输入框。**会徽上传**:`wire.h` `CG_MARK_UPLOAD`(0x0C03) `CGMarkUpload`(776=`{gid, u8 image[16*12*4]}`)、`CG_GUILD_SYMBOL_UPLOAD`(0x0722) `CGSymbolUpload`(8) + 追加原始文件字节;`NetStream.send_pending()``MarkClient``Mode`Download / UploadMark / UploadSymbol+ `set_upload_mark/set_upload_symbol` + `upload_done()`(发缓冲排空即完成)。`M2Client``upload_guild_mark(host, port, gid, Image)`(自动转 RGBA8 / 缩放到 16×12/ `upload_guild_symbol(host, port, gid, PackedByteArray)` / `get_mark_server()` + `guild_mark_uploaded(ok)` 信号。`guild_ui` 成员页「上传会徽」按钮(配了 `mark_port` + 图源才出),`game_scene._guild_mark_upload_image()` 优先 `res://ui/default_guild_mark.png` 否则现造占位。⬜ 等级/日志页、公会战应答(`/war` 系聊天命令) |
| 精炼 / 强化 | M | 🟡 | `wire.h``GC_REFINE_INFO`(0x051D)/`_NEW`(0x051E) `GCRefineInfo`(63) = type+pos+`RefineTable`(57src/result vnum + cost + prob + `RefineMaterial[5]`)`CG_REFINE`(0x050C) `CGRefine`(6)=`{pos,type}``EntityStore``RefineCue` + `drain_refine_cues()``M2Client``refine(pos,type)` + `refine_ask(dict)` 信号。`ui/refine_ui.gd``refine_ask` → 弹框(src→result / 成功率 / 费用 / 材料)→ [精炼]→`refine(pos,type)` / [取消]。⬜ 精炼结果动画 |
| 龙魂精炼 | M | 🟡 | `wire.h``GC_DRAGON_SOUL_REFINE`(0x051F) `GCDragonSoulRefine`(8)=`{sub_type, ItemPos}``CG_DRAGON_SOUL_REFINE`(0x050D) `CGDragonSoulRefine`(50)=`{sub_type, ItemPos grid[15]}``DS_SUB_*` 枚举(OPEN / DO_UPGRADE·IMPROVEMENT·REFINE / REFINE_SUCCEED / REFINE_FAIL_*);`DRAGON_SOUL_MAX_NUM`(180) + `WINDOW_DRAGON_SOUL`(5) 走 `mut_slot``EntityStore``DragonSoulCue` + `drain_ds_cues()` + `m_dragon_soul[180]` + `dragon_soul_slot()``M2Client``ds_refine(mode, cells)`mode 0/1/2 → DO_UPGRADE/IMPROVEMENT/REFINEcells 为背包格,grid[0]=龙魂)+ `get_dragon_souls()` + `ds_window_open` / `ds_refine_result(ok, sub_type, cell)` 信号。`ui/dragon_soul_ui.gd`L 键 / `ds_window_open` 自动弹):3 模式按钮 + 15 格(背包右键填/取)+ [执行]→`ds_refine` + 成功/失败提示。⬜ 龙魂属性表 / 结果动画 |
| 私人商店 / 道具商城 / Cube | M | 🟡 | **首版**`CG_MYSHOP`(0x0802) `CGMyShopHead`(38)+`MyShopItem`(13)×N`GC_MALL_OPEN`(0x0841)/`SET`(0x0842)/`DEL`(0x0843) 路由进 `EntityStore.m_mall[135]``CG_MALL_CHECKOUT`(0x0840)。Cube 走 `GC_CHAT`/`CHAT_TYPE_COMMAND` 文本总线(`EntityStore.apply_server_command``cube open/close/info/success/fail/r_list/m_info`)→ `CubeState` + `drain_cube_events()``send_cube_make/material_info/result_list``/cube …``M2Client``open/close_private_shop``get_mall_items`/`mall_checkout``get_cube`/`cube_make`/`cube_request_*` + `mall_opened`/`mall_changed`/`cube_opened`/`cube_closed`/`cube_changed`/`cube_result``ui/{private_shop,mall,cube}_ui.gd``net.entity_store`+`net.loopback_flow`+`shop_cube_mall_test.gd` 覆盖。⬜ 能量条、头顶招牌 3D、`START_EX` 多标签、39 格拖放、send 命令字对真服校准 |
**已验证**`net.entity_store` 扩测(party ADD/UPDATE/LINK/PARAMETER/REMOVE + INVITE 入队;messenger LIST 2 好友 + 在线位 + LOGIN 翻转;shop START owner+item / 错误 / ENDexchange START/ITEM_ADD/ELK_ADD/ACCEPT/ENDsafebox SIZE/SET/MONEY/DEL);`net.loopback_flow` MockServer 断言 `CG_PARTY_INVITE{vid}` / `CG_SHOP BUY{cnt,pos}` / `CG_EXCHANGE START{vid}` / `CG_SAFEBOX_CHECKIN{safe,inv}` 字节到位;`p8_test.gd`5 个 UI 节点 + FakeClient:队员面板 2 行 + 自动接受邀请、好友行 + 点名 whisper + 添加、商店 2 行 + 买 + 卖 + 错误本地化 + 关、交易两栏 + offer + 放金币 + 非 active 自动关、仓库开 + 金币 + 取出 + 存入挑空位)。13 套 GDScript 测试全绿,ctest 9/9,项目导入干净,iOS 编过。
**已验证(公会 + 精炼)**`net.entity_store` 扩测(GC_GUILD INFO 名/等级/资金/容量 + LIST `name_flag` 变长成员 + GRADE `[count][idx][GuildSubGrade]` 名/权限;GC_REFINE_INFO src/result/cost/prob + materials[5]);`guild_refine_test.gd`(公会窗无公会提示 / 信息行 / 成员按 grade 排序 + grade 名 / ★官员;精炼框 `refine_ask` 弹出 + 成功率 + 费用 + 材料行 + [精炼]→`refine(7,1)` + [取消] 不发包)。真机 `net_e2e``[GM-TEAM]` 公会数据解析正确。
**已验证(龙魂精炼)**`net.entity_store` 扩测(GC_DRAGON_SOUL_REFINE OPEN cue / REFINE_SUCCEED 带 cell+window / REFINE_FAIL_* / drain 清空);`dragon_soul_test.gd``ds_window_open` 弹出 + 默认精炼模式 + 切模式禁用当前按钮 + 背包右键按序填 15 格 + 非背包窗忽略 + 再次右键取出/末尾重放 + [执行]→`ds_refine(mode, [cells])` + 成功清空/失败留原因码 + 清空/关闭按钮)。
**已验证(会徽下载)**:新 CTest `net.guild_mark``parse_mark_idxlist` 2 条 → guild→mark_id + `needed_images` 升序去重;`rect_of``mark_id/1280` 选图、`%1280` 定位;合成 LZO1X 压缩块 → `parse_mark_block``apply_block` 拼图 → `mark_pixels` 逐像素回读;坏压缩数据 / 越界索引 / 截断 body 全部安全拒绝);`guild_mark_test.gd`guild_ui 无图时 TextureRect 隐藏 → `guild_marks_ready` 出图 16×12 → 离开公会收起;`app_flow` `mark_port=0` 不下载 / 配置后 `download_guild_marks(host,11002)` / `guild_mark_updated` 冷却外重下、1s 内不重下)。**独立连接 KX + `MarkClient` 分帧逻辑离线覆盖,未接真机**(联调服未开会徽服端口)。附带修复:`game_scene.gd``_skill_master()` 之前被误插进 `setup()` 中段,截断了后半段(导致 `assets_root` 等未声明)——已把它移到 `setup()` 之后,多个依赖 `game_scene` 的 GDScript 套件恢复真绿。
**已验证(会徽上传)**`net.loopback_flow``MockServer::Mode::Mark`:真 loopback socket 跑完整 KX → `CG_MARK_LOGIN{handle,random_key}` 字节到位 → `CG_MARK_UPLOAD{gid=77, image[768]}` 服务端逐字节校验和一致 → `upload_done()`;同法验 `CG_GUILD_SYMBOL_UPLOAD{gid=88}` + 2000 字节原始附加。`guild_mark_test.gd`:未配 mark server 无「上传会徽」按钮 → 配好后按钮出现 → `upload_guild_mark("1.2.3.4",11002,88,img)` + `guild_mark_uploaded(true)` → 状态更新。23 套 GDScript 全绿,ctest 10/10,导入干净,iOS 编过。
**已验证(公会战 + 公会技能)**`net.entity_store` 扩测(GUILD_GC_NAME id→名注册、SKILL_INFO 点数/12 等级/公会点、WAR ON_WAR 状态 + 事件 drain、WAR_LIST 2 对 → WAR_END_LIST 删 1、WAR_POINT drain);`guild_war_skill_test.gd`(技能页无数据提示 → `guild_skill_changed` 出点数行 + GUILD 技能名 + 等级 + 第 12 技能配 `levels[11]`;已学技能有「施放」→ `use_guild_skill(4051,0)`;公会战页无战提示 + 宣战 → `declare_guild_war("Tigers")` + `guild_war_changed` → 「交战中」+ GvG 行「◀我方」)。23 套 GDScript 全绿,ctest 10/10,导入干净,iOS 编过。**真机(2026-08-31`net_e2e`**`[GM-TEAM]` 账号进游戏后服务器自动推 `GUILD_GC_SKILL_INFO`17B body 解析正确(skill_point=19 / guild_point 0/2300 / 12 等级);`GUILD_GC_WAR` 无战状态解析正确;trace `GC_GUILD` 子包 len=171/40/83/22/21/9/5 无 unknown。
**未完成**:私人商店打磨;商店 `START_EX`;交易 / 仓库道具图标网格;仓库密码框 + 商城页;公会战应答聊天命令;公会等级/日志页;龙魂属性表 + 精炼结果动画。
## P9 — 世界系统(L,🌐,分散)
| 任务 | 规模 | 状态 | 落点 |
|---|---|---|---|
| 换图 / 传送 | S | 🟡 | `wire.h` `GCWarp`(18x/y/addr/port)。`EntityStore` `WarpCue` + `drain_warps()``same_server()` = addr==0)。`M2Client` `warp(pos, same_server)` 信号(pos 已转 Godot 米)。`game_scene._on_warp`:同服 → 直接挪玩家 + 相机 snap;跨服(addr≠0)→ warning,归 P10 重连串场 |
| 频道 | S | ✅ | `wire.h` `GCChannel`(5)。`M2Client` `get_channel()` + `channel_changed(ch)` 信号。小地图角标显示 `CH n``hud.set_channel` 若有也调) |
| 游戏内时间 / 昼夜 | S | ✅ | `wire.h` `GCTime`(12unix 秒 i64)。`M2Client` `get_server_time()` + `time_changed(epoch)``world/world_time.gd`:epoch → 日内秒 → 太阳 pitch/azimuth + 光强/光色(夜黑冷蓝 → 黄昏橙 → 正午暖白)+ `Environment` ambient/背景色。无 GC_TIME 时退回系统本地时间 |
| 小地图 | M | 🟡 | `ui/minimap.gd`:右上角圆形,北朝上,`_draw` 画底圆 + 外环 + 玩家朝向箭头 + 实体点(怪红 / 其它蓝)+ NPC 点(传送青 / 其它黄)+ 任务标记橙方。世界→图 = `(d.x, d.z)*SCALE` 夹到半径。⬜ 缩放 / 拖动 / 大地图窗 / atlas 底图 |
| NPC 位置 / 世界任务标记 | M | 🟡 | `wire.h` `GCNPCPosition`(6 + `count`×`NPCPositionEntry`78) / `GCTargetCreate`(46) / `GCTargetUpdate`(16) / `GCTargetDelete`(8) + `CREATE_TARGET_TYPE_*``EntityStore` `m_npc_marks` + `m_markers` map + `npc_marks()`/`markers()` + dirty。`M2Client` `get_npc_marks()`/`get_world_markers()` + `npc_marks_changed`/`world_markers_changed`。小地图消费。⬜ 世界空间 3D 箭头 / 屏幕边缘指示 |
| 天气 / 环境粒子 | S | 🟡 | `fx/weather.gd`:跟随相机上方的 `GPUParticles3D` 盒发射,`set_weather("snow"/"rain"/"none")` 切重力 / 速度 / 尺寸 / 颜色 / 湍流。⬜ 由地图 `.msenv` 属性驱动、闪电 / 雾 |
| 坐骑 | S | 🟡 | `wire.h` `GCMount`(21)。`Entity.mount_vnum` + `drain_mount_changes()``M2Client` `mount_changed(vid)` 信号 + `entity_dict``mount_vnum`。⬜ 换骑乘模型 / 坐骑动作集 / 移速 |
| 地形贴花 | S | ⬜ | `GameLib/TerrainDecal.cpp` —— 血迹 / 施法阵 / 脚印(Godot `Decal` 节点) |
| 钓鱼 / 副本 | M | ⬜ | `GC_FISHING`(0x0B10) `DUNGEON`(0x0B11) |
**已验证**`net.entity_store` 扩测(warp 入队 + same_servertime set + dirty oncechannel set`GC_NPC_POSITION` 2 条 markmarker create→update→deletemount → `Entity.mount_vnum` + change);`p9_test.gd`(小地图:view + `CH` 角标 + `_to_map` 北朝上/东朝右/夹半径;world_time:正午 `day_fraction≈0.5` + 太阳亮,午夜 ≈0 + 太阳暗;weatheroff→snow→rain(雨落更快)→offwarp 同服信号)。14 套 GDScript 测试全绿,ctest 9/9,项目导入干净。
**未完成**:大地图窗 + atlas 底图、地形贴花、钓鱼 / 副本、坐骑换模型、世界空间任务箭头、天气按地图驱动、跨服换图(P10)。
## P10 — 网络补全(M,🌐)
| 任务 | 规模 | 状态 | 落点 |
|---|---|---|---|
| **LOADING → PHASE_GAME**(真机端到端的最后一跳)| S | ✅ | `wire.h` `CGClientVersion`(0x000D70Bfilename+timestamp)。`GameClient`:收到 `GC_MAIN_CHARACTER` 后**自动发 `CG_CLIENT_VERSION`**fork 用固定 timestamp `1215955205`)—— 这是服务器放行到 PHASE_GAME 的门槛,之前缺它就在 phase 4 断线。`net_e2e` 真机验证:`phase = 5 / IN GAME`,收到 121 个实体 + HP/SP/level + 20 格背包 + 好友列表 |
| Loading 相位 | S | ✅ | `ui/loading_screen.gd``CanvasLayer` layer 60):`phase_changed` → "login/select→连接中/载入角色"、"loading→载入地图" 显示转轮 + 阶段文案 + 可选进度条;"game" 隐藏。断线也收起 |
| 选服 / 选频道 | S | 🟡 | `net/serverinfo.gd``class_name ServerInfo`):解析 TSV 服务器表(`name / auth_host / auth_port / game_host / game_port / channels`),内置当前联调服默认项。`address(server, channel)` → 频道 N 的 game 端口 = base + (N-1)×10(经典 Metin2 约定)。`app_flow.gd` 登录表单里做服务器 / 频道下拉。⬜ 频道在线人数轮询(`ServerStateChecker` UDP 探针) |
| Offline / 断线重连 | S | ✅ | `ui/reconnect_ui.gd`layer 70):`disconnected(reason)` → 遮罩 + "N 秒后自动重连(次数/上限)" 倒计时 → `M2Client.reconnect()`(已有);[立即重连] / [停止] 按钮;`entered_game` 或非 offline 的 `phase_changed` 自动收起。最多重试 5 次 |
| charselect 串场 | M | ✅ | `app_flow.gd``Node` 场景根):一个 `M2Client` 贯穿全程,状态机 `LOGIN → SELECT → GAME``char_list` → 选人列表;点角色 → `select_character``entered_game` → 实例化 `game_scene``setup`LOGIN/SELECT 期间断线 → 回登录,GAME 期间断线 → 保留场景交给 `ReconnectUI``LoadingScreen` + `ReconnectUI` 常驻。`build_game_scene` 开关便于 headless 测状态机 |
| Metin2 风选人页 | M | ✅ | `ui/char_select_screen.gd` 取代裸按钮列表:全屏 `select.jpg` 背景 + 右侧 `SubViewport``PlayerView` 真模型(正面 + `wait` + 缓摆,**包围球** `r/sin(fovV/2)` 自适应机位,不再切远侧手)+ 左上职业名(locale `name_<class>.sub``select.dds`DDS 走 `Metin2World.load_dds`;退中文描边字)+ 左侧 `board` 板(帮会徽 / **国家名+国旗** `get_empire()` / 帮会名 / 名称 / 等级 / 游戏时间 / 体力·智力·力量·敏捷)+ 开始/创建/删除/退出 + ◀▶ 槽位切换(`_pad_slots` 补空槽)。**建号/删号 1:1**:`CG_CHARACTER_CREATE`(0x0201)/`CG_CHARACTER_DELETE`(0x0202) + `GC_PLAYER_CREATE_SUCCESS/FAILURE`(0x020C/D) + `GC_PLAYER_DELETE_SUCCESS`(0x020E)/`WRONG_SOCIAL_ID`(0x020F)`GameClient.create/delete_character` + `drain_char_events` + `replace_slot/clear_slot`(服务器补丁槽位不重发);`M2Client` 同名方法 + `char_created/char_create_failed/char_deleted/char_delete_failed` 信号 + `get_empire/get_slot_count`;选人页建号弹窗(职业+名称+各职业起始四维)、删号弹窗(secret 删除码);`app_flow._on_char_list` 在 SELECT 只 `set_chars` 不整屏重建。`net.loopback_flow` MockServer + `char_create_delete_test.gd` 覆盖。C++ `CharSlot`+dict 补 `play_minutes/st/ht/dx/iq/main_part/hair_part``ui_assets` 加运行时 DDS 解码 |
| attack CRC | S | ✅ | `net_e2e` 真机实测:进游戏后发 `CG_MOVE` + `CG_ATTACK``crc_proc=0 crc_file=0`),保持连接 10s 不被踢。**结论:0 CRC 服务器接受,不需要占位 CRC** |
| ESC 系统菜单 + 游戏设置窗(`uisystem.SystemDialog` + `uigameoption.OptionDialog`| M | ✅ | `system_menu_ui.gd``systemdialog.py`8 键:system_option / game_option 开子窗、`change_button``/phase_select``logout_button``/logout``mall_button``/in_game_mall``exit_button`→quit。ESC 改开此菜单。`game_option_ui.gd``gameoptiondialog.py`):`/setblockmode <mask^bit>`EBlockAction 位 1<<0..1<<5,本地 `_block_mode` + cfg 持久化)+ `/pkmode {0,1,2,4}` PK radio + 6 组显示开关持久化 `user://system_option.cfg [gameopt]`(渲染钩子待补)。`system_menu_ui_test.gd` 覆盖。C++ 无改动 |
| 组队成员信息板(`uiparty.PartyMemberInfoBoard`| M | ✅ | `wire.h` `CGPartySetState`(10) + `EPartyRole``send_party_set_state` / `M2Client.party_set_state``get_party()``state`+`affects[7]``party_ui.gd` 每员 strip = 角色状态按钮(队长弹菜单 `CG_PARTY_SET_STATE` 攻/坦/狂/辅/宗/防 + 踢人)+ 名字(★) + HP gauge + affect chip;顶部 EXP 分配开关 + 组队治疗。点名字 → `set_target``p8_test` 组队段扩覆盖;顺带修 `p8_test._init` 未 await 的隐患 + `shop_ui.refresh` queue_free 延迟 bug |
| 系统设置窗(`uisystemoption.OptionDialog`| S | ✅ | `ui/system_option_ui.gd`:装 `systemoptiondialog.py` 真 uiscript,绑定照 `uisystemoption.py`:音乐/音效音量滑条→`Audio.master_bgm/sfx`(含在播 BGM 即时)、`camera_short/long``game_camera.max_dist`(近 11/远 20)、`fog_level0/1/2`(浓/中/淡)→`Environment.fog_density``tiling_*` Godot 无对应保留占位。持久化 `user://system_option.cfg`= `systemSetting` 配置),setup 读回即时生效,`.msenv` 换 env 后重贴。`game_scene` ESC 呼出。`system_option_ui_test.gd` 覆盖 |
| 私人商店开설창(`PrivateShopBuilder`)| S | ✅ | 增量 36:布局改走真 uiscript `privateshopbuilder.py`40 格 grid + NameLine 盖 LineEdit + Ok/Close,候选面板挂窗右侧)。`itemStock` 交互(拿起/落位/价格弹窗/排序+display_pos/撤下/개설/철수)不变。测试拆出 `private_shop_ui_test.gd`。以下为原 | `private_shop_ui.gd` 重做成 `uiprivateshopbuilder.py``itemStock` 模型:左侧背包候选「拿起」+ 右侧 40 格(`shop.SHOP_SLOT_COUNT`);拿件点空格 → 价格弹窗(`MoneyInputDialog`)→ 落位(`AddPrivateShopItemStock`),点占用格 → 撤下(`DelPrivateShopItemStock`)。개설 把 stock 按格号排序、`display_pos=格号`、上限 39`PRIVATE_SHOP_ITEM_MAX_NUM`)→ `open_private_shop(sign, [{vnum,count,inv_cell,price,display_pos}])``TPacketCGMyShop`+`TShopItemTable`×N)。`shop_cube_mall_test` 重写覆盖。C++ 无改动 |
| 商店 `SHOP_GC_START_EX`(多货架)| S | ✅ | `entity_store` 解析 `SHOP_GC_START_EX`(10) = `{u32 owner_vid, u8 tab_count}` + `tab_count`×`{ShopTabHead(name[32]+coin_type), ShopItem[40]}``ShopEntry.pos` 保槽位,`m_shop_tabs``ShopTab{name,coin_type,items}`),`SHOP_GC_START` 也按 40-槽填 pos,END 清货架。`M2Client.get_shop()``{vid,open,tabs:[…]}``shop_ui.gd` 多货架按钮 + 买位置 `tabIdx*40+slot`1:1 `uishop.py GetIndexFromSlotPos`)。`net_entity_test` 双货架合成包 + `p8_test` 切页 / `buy pos 5` / `buy pos 43` |
| 选魔石窗(样板第 2 例)| S | ✅ | `ui/select_item_ui.gd`:装 `selectitemwindow.py`5×8 grid),填格照 `uiselectitem.py.RefreshSlot`(只 `ITEM_TYPE_METIN`=10 + `GetItemGrade`(内部名末位数字)≤2,≤54 个,升序映射背包格)。点格 → `client.script_select_item(invCell)``CG_SCRIPT_SELECT_ITEM` 0x0903 = `net.SendSelectItemPacket`+ 关窗;关闭 → `script_select_item(0)`。触发链:`quest_dialog.parse_script` 识别 `[SELECT_ITEM]` token → `select_item_requested` 信号(= `PythonEventManager EVENT_TYPE_SELECT_ITEM``BINARY_OpenSelectItemWindow`)→ `game_scene``select_item_ui.open``select_item_ui_test.gd` 覆盖 |
| 角色状态窗(1:1 窗口样板)| M | ✅ | `ui/char_status_ui.gd``UiScript`+`UiBuild` 装载 `characterwindow.py` 真布局,数值逐字对照 `uicharacter.py.RefreshStatus`Level/Exp/RestExp、HP/SP、STR/DEX/HTH/INT、ATT=`(min|max)+ATT_BONUS+ATTACKER_BONUS`、DEF、MATT=`MAG_ATT+(min|max)MAGIC_WEP`、MDEF、ASPD/MSPD/CSPD/EREPointTypes 索引取 m2dev `Packet.h`)。加点 /`client.say(0,"/stat ht"|"/stat- ht")`(同原 `statusPlusCommandDict`);`POINT_STAT>0` 才显加点按钮。4 页 tab `SetState` 切换。名称/帮会/头像取 `get_entity(get_main_vid())``game_scene` V/C 热键。`char_status_ui_test.gd` 覆盖。**后续窗口(选道具网格 / 商店 START_EX / 私人商店 39 格 / 系统设置 …)照抄此样板:uiscript 装载 + 逐字数值绑定 + 原聊天命令 / 封包** |
**已验证**`net.loopback_flow` MockServer 断言「`GC_MAIN_CHARACTER` 后客户端自动发 `CG_CLIENT_VERSION`」;`p10_test.gd`serverinfo 内置默认 + 文件解析 + 频道端口偏移;loading 遮罩 phase=loading 显示 / progress 条 / phase=game 隐藏;reconnect 断线 → 倒计时耗尽调 `reconnect()` + 手动重连 + `entered_game` 收起;app_flow 状态机 LOGIN→连接用 serverinfo 地址→`char_list`→SELECT→点角色→`select_character``entered_game`→GAMESELECT 断线→LOGIN)。15 套 GDScript 测试全绿,ctest 9/9。**`net_e2e` 真机:auth → game → 选人 → LOADING → PHASE_GAME 全程打通。**
**真机字节校准 + 收尾(2026-08-30,已做)**
- `GC_CHAR_ADD_INFO`(0x020797B) 补上 → 实体名/等级/公会/骑乘出(NPC「Old Man」实测正确);
`GC_CHARACTER_UPDATE`(0x0209) 按 `TPacketGCCharacterUpdate` 全解(parts/guild/alignment/pk/mount)。
- `ch_type` 改在 GDScript 按 race 查 `mob_proto.type` 分类(`net_play._entity_kind`/`_entity_name`),
`game_scene``mob_proto``GC_PLAYER_POINTS` 本来就对(gold 13.5 亿 = GM 号真实值,
exp/next_exp 按 unsigned 输出)。
- **3D 名字标签刷新**`ChangeKind::Info` + `entity_info` 信号 → `net_world._on_info` 刷 Label3D
`name_resolver` 让怪名走 `mob_proto`
- **跨服换图**`GC_WARP` addr≠0):`M2Client.warp_to_game_server` 拆连接、复用 `login_key` 直连新
game server。**断点快连**`reconnect()` 优先直连 game server 省一次 auth 往返。
- **频道负载查询**`net/channel_status.gd` = `CServerStateChecker` 端口(明文 TCP`CG_STATE_CHECKER`
0x000F → `GC_RESPOND_CHANNELSTATUS` 0x0010 → `{port: 0关/1正常/2拥挤/3爆满}`)。真机 `11011`
`{11011:1,11012:1,11013:1,11991:1}``serverinfo` 频道口步长改 +1`port_step` 可按服覆盖)。
- **怪 / NPC 真 3D 模型**`ui/mob_view.gd`race → `root/npclist.txt` 代号 → 目录逐段回退
`bear_brown→bear`)→ `Metin2Model`(网格 / 贴图用完整代号)+ `motlist.txt` 动作),
`game_scene._make_entity_model``net_world` 默认工厂,失败回退占位胶囊。
`net_world._attach_nameplate` 抽出,真模型也挂名字 + HP 条。翻译版 `mob_proto` `szName` 被本地化
"Brown Bear")解不出目录,故必须走 `npclist.txt`
- **已在局内 / 重连补拉实体**`net_world.catch_up()` 遍历 `client.get_entities()` 补 spawn
(协程化的 `setup()``entity_spawned` 接得晚,会漏初始 spawn 洪流);`game_scene`
`set_local_vid` + `net_play._on_main_set``catch_up`,避免本地玩家被画成压身的蓝胶囊。
详见 `CLIENT-PORT.md` §3。**23 套 GDScript + 10 CTest + 项目导入 + macOS/iOS 扩展全绿。**
**剩的不是代码问题**:字面在线人数(Metin2 协议只有 4 态负载,要数字得改服务器)、全量 monster 资产。
---
## 里程碑
**P0P10 首版全部完成**2026-08-30)。真机端到端(`net_e2e`)从 auth 握手一路打通到
PHASE_GAME —— 收到 121 个实体、HP/SP/等级、20 格背包、好友列表。18 套 GDScript + 9 CTest +
项目导入 + macOS/iOS 扩展全绿。
| 里程碑 | 含 | 首版状态 | 距「打磨完成」还差 |
|---|---|---|---|
| **M1 可打** | P0 闭环 + P4 硬直 / 连击 / 相机抖 / 死亡窗 | ✅ 首版 | 攻速 / motion index 按 `POINT_ATT_SPEED` 细化、武器拖尾、相机锁定 |
| **M2 角色养成** | P1 UI 层 + P2 物品 / 装备(武器 / 盾 / 身体 / 头盔 / 头发 / armor shape / 时装)+ P6 技能(分类页 + 技能特效表 + 内建特效)+ P3 聊天 | ✅ | 加点等级公式(服务器管)、真弹道 |
| **M3 世界** | P7 任务 / NPC + P9 世界系统 + P10 网络补全 | ✅ 首版 | `[INPUT]` / 选道具 / 立绘、大地图窗 + atlas、地形贴花、跨服换图、断点续连 |
| **M4 社交** | P8 组队 / 好友 / 商店 / 交易 / 仓库 / **公会**(技能页 + 公会战 + 会徽上/下载)/ **精炼** / **龙魂精炼** | 🟡 首版(12 件) | 龙魂属性表 + 结果动画、私人商店打磨、公会等级/日志页 |
| **特效** | P5 EffectLib`.mse` → GPUParticles | 🟡 首版 | `.dds` 纹理、`.mde` mesh、绑骨骼、skill / 命中特效表 |
### 下一步优先级(都是「首版 → 打磨」)
1. ~~真机字节校准第二轮~~ ✅ 已做(2026-08-30):`GC_SKILL_LEVEL_NEW`(0x021B,本服才发这个,
之前技能窗全空) + `GC_QUICKSLOT_*`(0x0519/1A/1B) 补上;`GC_NPC_POSITION` / `GC_ITEM_UPDATE` /
`GC_CHAT` / `GC_AFFECT_ADD` / `GC_SPECIAL_EFFECT` / `GC_QUEST_INFO` 逐字节确认全对。详见 `CLIENT-PORT.md` §3。
2. ~~换装完整~~ ✅ 已做(2026-08-30):`Metin2Model` 加第二刚体挂点(盾,`Bip01 L Hand``_load_attach`
抽公共加载器);`equip_model` 补 WEAR_SHIELD / WEAR_HEAD(头盔覆盖 hair 槽)/ 头发随 `parts[HAIR]`
**armor→shape 真表**proto 读器加 `alValues[6]`@191`equip_model._armor_shape_default` = `item_proto
values[3]`(非 0)否则 =vnum(对齐 `__ArmorVnumToShape`+ armor `specular``model.specular_power`
(§2.7)。**时装**Metin2 不发独立 costume 槽包,服务器把 costume vnum 写进主角 `parts[ARMOR/HAIR]`
`equip_model` 每槽改成 `_eff()``parts[]` 非 0 优先,否则回退 `get_equipment()`),大编号 `parts[HAIR]`
`item_list.model`(假发)。`GC_CHARACTER_UPDATE` parts 变 → 推 `Info` 变更触发全刷。也是渲染
远端玩家外观的同一条路。
3. ~~被动 / 支援技能树~~ ✅ 已做(2026-08-30):`skilldesc.txt` 列偏移修正(旧代码差 1 列,`motion_idx`
一直读成 0);`skill_ui` 三分类页 `[主动][辅助][坐骑]``for_category`),被动技能无 `[]``master_type`
显示为 `Lv X M/G/P`
4. ~~技能特效表~~ ✅ 已做(2026-08-30):`fx/skill_fx.gd`skill id + master → `<motion_name>_<N>``skilldesc`
col13),`GC_SPECIAL_EFFECT` id → `SPECIAL_FX` 小表;`quickbar.skill_activated` + `effect_cue.special` 接上。
5. **全量资产** —— 换完整 monster / npc / effect 包,`mob_view` / `effect_registry` / `skill_fx` 立刻全量。
6. ~~公会 + 精炼~~ ✅ 已做(2026-08-30):`GC_GUILD`(0x0730) INFO/LIST/GRADE 解析 + `ui/guild_ui.gd`G 键,
真机 `[GM-TEAM]` 验证);`GC_REFINE_INFO`(0x051D) + `ui/refine_ui.gd``refine_ask` 弹框 → `refine(pos,type)`)。
~~公会战 / 技能页~~ ✅ 已做(2026-08-30):`GUILD_GC_SKILL_INFO`(12)/`WAR`(15)/`GUILD_WAR_LIST`·`END_LIST`(17/18)/
`WAR_POINT`(19)/`NAME`(16) 解析 + `GUILD_CG_USE_SKILL`(9)`M2Client` `get_guild_skill`/`get_guild_wars`/
`use_guild_skill`/`declare_guild_war`(`/war` 命令) + 4 信号;`guild_ui` 改三页 Tab(成员/技能/公会战)。
9. ~~会徽上传~~ ✅ 已做(2026-08-30):`CG_MARK_UPLOAD`(0x0C03`{gid, u8 image[768]}`) / `CG_GUILD_SYMBOL_UPLOAD`
(0x0722) + 追加文件字节。`MarkClient``Mode`Download/UploadMark/UploadSymbol+ `set_upload_mark/_symbol`
+ `upload_done()``NetStream.send_pending()` 归零即完成)。`M2Client.upload_guild_mark(host,port,gid,Image)`(自动转
RGBA8 + 缩放 16×12/ `upload_guild_symbol` / `get_mark_server` + `guild_mark_uploaded(ok)``guild_ui` 成员页
「上传会徽」按钮。`net.loopback_flow``MockServer::Mode::Mark` 真 socket 校验字节。
7. ~~龙魂精炼~~ ✅ 已做(2026-08-30):`GC_DRAGON_SOUL_REFINE`(0x051F) / `CG_DRAGON_SOUL_REFINE`(0x050D
`grid[15]`) + `DS_SUB_*` 枚举;`EntityStore.DragonSoulCue` + `m_dragon_soul[180]``WINDOW_DRAGON_SOUL`
`mut_slot`);`M2Client.ds_refine(mode, cells)` + `ds_window_open` / `ds_refine_result``ui/dragon_soul_ui.gd`
(L 键 / 自动弹,3 模式 + 15 格背包右键填 + [执行])。剩龙魂属性表、精炼结果动画。
8. ~~会徽下载~~ ✅ 已做(2026-08-30):`MARK_*`0x0C0x/0x0B15)独立连接。`NetStream``on_cipher_active()`
+ `on_raw()` 钩子让子类自定义分帧(`GC_MARK_*` 用 u32 `buf_size`)。`mark_image.{h,cpp}` `MarkImageSet`
LZO1X 解 64×48 块 → 512×512 → `mark_pixels`+ `mark_client.h` `MarkClient`KX → `CG_MARK_LOGIN{handle,
random_key}``CG_MARK_IDXLIST` → 逐图 `CG_MARK_CRCLIST`(全 0) → `GC_MARK_BLOCK`)。`M2Client.download_guild_marks`
/ `get_guild_mark_image``Image``guild_ui` 显示 16×12 会徽;`serverinfo` `mark_port` 第 8 列(0=跳过);
`app_flow` 进游戏连、`guild_mark_updated` 冷却重连。CTest `net.guild_mark` + `guild_mark_test.gd`。**未接真机**
(联调服未开会徽服端口)。
9. **私人商店打磨**、商店 `START_EX`、道具图标网格 —— 按需。(拍卖行不在 m2dev-client 内)
## 通用做法
1. **每个 P 先做 💻 部分**`wire.h` 加封包 + `EntityStore` 解析 + 合成封包单测(`net_entity_test` 风格)+ 假 client 驱动 UI`netbridge_test` 风格)。
2. 🌐 端到端用 `build/extension/net_e2e`(真机跑 auth→game→选人→进游戏);`MT_NET_TRACE=1` 逐包 header/len/consumed、`MT_NET_DUMP=1` 逐包 hex,对字节用。
3. UI 一律走 P1 的 uiscript 装载器,不手搓布局。
4. 封包号 / 结构以 `../m2dev-client-src-main/src/UserInterface/Packet.h` 为准,`static_assert` 尺寸;**真机字节和参考头文件不符时以真机为准**(已踩:`GC_LOGIN_SUCCESS4``bType``CG_CLIENT_VERSION`)。
5. 玩法逻辑尽量**服务器驱动、客户端只渲染**(尤其 quest / 商店 / 公会),少搬业务规则。
+39 -34
View File
@@ -1,15 +1,20 @@
# Godot 渲染 Demo — 开发计划 # Godot 渲染 Demo — 开发计划
> 内部研究方案,不对外公开。 > 内部研究方案,不对外公开。
> 与 [`PLAN.md`](./PLAN.md) 是**两条并行的跨平台基座候选**:本文件评估「Godot 4 + 自研资源 loader」,`PLAN.md` 评估「自研引擎 + bgfx RHI」。 >
> 复用 [`../libgr2`](../libgr2)、[`../formats`](../formats) 与 [`../oracle`](../oracle) 的真值数据。 > **2026-08-29 更新**:并行的 bgfx 自研引擎方案(`xrender-poc` / [`reference/PLAN.md`](./reference/PLAN.md)
> 经中期评审([`MIDREVIEW.md`](./MIDREVIEW.md)**停止开发**,本方案成为唯一在研路线。
> `libgr2` / `formats` / `oracle` / `tools` 已从 xrender-poc **vendored 进本仓库**
> `../libgr2`、`../formats`、`../oracle`、`../tools`),不再是 submodule / sibling 引用。
> 下文中出现的 `xrender-poc/xxx` 路径按 [`reference/README.md`](./reference/README.md) 的映射表读。
> bgfx demo 截图作为交叉核对基线留在 `../test/bgfx-reference/`。
| | | | | |
|---|---| |---|---|
| 状态 | PoC 规划 | | 状态 | Phase 1macOS)基本完成,见 [`MIDREVIEW.md`](./MIDREVIEW.md);未完成工作分层清单见 [`BACKLOG.md`](./BACKLOG.md) |
| 技术栈 | Godot **4.7.1** stable · GDExtension`godot-cpp` `4.7` 分支)· C++20 | | 技术栈 | Godot **4.7.1** stable · GDExtension`godot-cpp` 精确提交 `101ae38`4.7 API)· C++20 |
| 目标平台 | **Phase 1macOS**(当前)→ Phase 2iOS · Android | | 目标平台 | **Phase 1macOS**(当前)→ Phase 2Android(一加 13 / Vulkan)· iOSiPhone 16 / Metal)。自用,三台现代设备,不涉及 GLES3 / Compatibility |
| 判定周期 | Phase 1 ≈ 3.5–5 周(单人)→ **中期评审**(不下结论)Phase 2 后 → **go / no-go** | | 判定周期 | Phase 1 ≈ 3.5–5 周(单人)→ **中期评审**Phase 2 = 三设备各一次 bring-up + 性能留档(自用,无对外 go/no-go) |
| 复用 | `libgr2`gr2 v6 读取器)· `formats/`textscript · msa · msm)· `oracle/` golden · bgfx demo 截图 | | 复用 | `libgr2`gr2 v6 读取器)· `formats/`textscript · msa · msm)· `oracle/` golden · bgfx demo 截图 |
| 自研 | `Metin2Model` GDExtension 节点(gr2 → `Skeleton3D` + `ArrayMesh` + 材质) | | 自研 | `Metin2Model` GDExtension 节点(gr2 → `Skeleton3D` + `ArrayMesh` + 材质) |
@@ -20,11 +25,11 @@
| 阶段 | 平台 | 里程碑 | 回答什么 | 产出 | | 阶段 | 平台 | 里程碑 | 回答什么 | 产出 |
|---|---|---|---|---| |---|---|---|---|---|
| **Phase 1** | macOS | M0' · M1 · M2 · **M2.5** | ① gr2 → Godot 场景对象能否正确映射;② 动画运行时(采样 / retarget / 事件)能否移植;③ **Metin2 观感能否在 Godot 里还原** | 中期评审报告:桥接层工作量实测 + 观感对比 + 剩余风险 | | **Phase 1** | macOS | M0' · M1 · M2 · **M2.5** | ① gr2 → Godot 场景对象能否正确映射;② 动画运行时(采样 / retarget / 事件)能否移植;③ **Metin2 观感能否在 Godot 里还原** | 中期评审报告:桥接层工作量实测 + 观感对比 + 剩余风险 |
| **Phase 2** | iOS · Android | M3(·M4 可选) | ④ 跨平台三端一致;⑤ 移动端性能 / 生命周期 | **go / no-go 结论** | | **Phase 2** | Android(一加 13)· iOSiPhone 16 | M3(·M4 可选) | ④ 三端渲染一致;⑤ 两台移动设备性能 / 生命周期 | 三设备 bring-up 报告 + 性能留档 |
**关键澄清(写给评审)**Godot 在 macOS 上渲染一个蒙皮 gr2 **几乎不可能失败**。Phase 1 不是用来判断"该不该押 Godot"的——那个只有 Phase 2 能答。Phase 1 的价值是把**平台无关**的 GDExtension 工程(占本方案工程量大头)做完并确认视觉保真度可达,从而让 Phase 2 只剩"移动端 + 性能"这一个真未知 **关键澄清**Godot 在 macOS 上渲染一个蒙皮 gr2 **几乎不可能失败**Phase 1 的价值是把**平台无关**的 GDExtension 工程(占工程量大头)做完并确认视觉保真度可达。bgfx 自研引擎方案已于 2026-08-29 停止开发,Godot 是唯一在研路线;本项目自用、不对外发布,**没有"押哪条路线"的产品决策**。Phase 2 只剩"三台目标设备(Mac / 一加 13 / iPhone 16)上能不能跑 + 性能够不够自己用"
**进入 Phase 2 的前置**M2.5 门禁通过,且中期评审判定"桥接层工作量与观感可接受"。否则在此转回 bgfx route,损失仅为 GDExtension 桥接层(≈ glTF importer 量级),`libgr2` 完整保留 **进入 Phase 2 的前置**M2.5 门禁通过中期评审确认桥接层与观感可接受即可推进。三台目标设备都是现代硬件,全程不涉及 GLES3 / Compatibility 渲染器,不做中低端 / 老机器
--- ---
@@ -38,8 +43,8 @@
### Phase 2 要证明的两件事(移动端,本轮不做) ### Phase 2 要证明的两件事(移动端,本轮不做)
4. **跨平台一致** — 同一 GDExtension + Godot 工程在 iOS 真机 + Android 真机(Vulkan + Compatibility)渲染与桌面一致。 4. **三端一致** — 同一 GDExtension + Godot 工程在 iPhone 16Metal+ 一加 13Vulkan)渲染与 Mac 一致。
5. **性能 / 生命周期达标**中端机单角色 / 多角色帧率达标,后台 / 恢复稳,Godot 节点开销可控。 5. **性能 / 生命周期够自用**两台设备单角色 / 多角色帧率可接受(旗舰基线,非发布门槛),后台 / 恢复稳,Godot 节点开销可控。
### 明确排除(两个阶段都不做) ### 明确排除(两个阶段都不做)
@@ -49,13 +54,11 @@ SpeedTree 植被、地形(Metin2 户外地图)、UI / Python 脚本层、ete
### 判定口径 ### 判定口径
- **M2.5 门禁通过 + 中期评审"可接受"** = 进 Phase 2。 - **M2.5 门禁通过 + 中期评审"可接受"** = 进 Phase 2。中期评审已判定桥接层工作量与观感均可接受([`MIDREVIEW.md`](./MIDREVIEW.md));bgfx route 已停,无「转回」分支。
- **门禁失败区分两类**(同 `PLAN.md` §01 口径): - **Phase 2 若撞上方案性死路**(自写蒙皮着色器在 iPhone 16 / 一加 13 上跑不出、Godot 节点开销在自用规模下就卡)——记录清楚、就地想办法(回落 CPU LBS / `RenderingServer` 直调 / uniform 传骨骼),不再有另一条引擎路线兜底。
- **工程性延期** — 材质差色、某曲线子类型没实现、某 API 用错。有明确修法,记下继续 - **Phase 2 末交付**:三设备 bring-up + 性能留档报告(自用参考,不做对外选型结论)
- **方案性死路** — 例如 Godot 节点 / 骨骼模型无法表达 Metin2 的多部件挂点体系、或多 stage 固定管线在 `ShaderMaterial` 里无法近似到可接受、或每帧写骨骼在合理规模下 Mac 上就已经卡。这才触发"转回 bgfx route"。
- **最终交付(Phase 2 末)** 一页纸对比结论:与 bgfx route 在 **工作量 / 性能 / 视觉保真度 / 长期维护成本** 四轴上的取舍。
### 与 bgfx route 的关系 ### 与 bgfx route 的关系(历史对照,bgfx route 已于 2026-08-29 停止开发)
| | bgfx route`PLAN.md` | Godot route(本文件) | | | bgfx route`PLAN.md` | Godot route(本文件) |
|---|---|---| |---|---|---|
@@ -82,8 +85,7 @@ mtgodot-poc/
gr2_bridge.{h,cpp} libgr2 POD 视图 → Godot 数组打包;basis + 单位换算(集中一处) gr2_bridge.{h,cpp} libgr2 POD 视图 → Godot 数组打包;basis + 单位换算(集中一处)
dds_loader.{h,cpp} DDS → Godot Image(散文件路径用;res:// 里的 .dds 交 Godot 原生导入) dds_loader.{h,cpp} DDS → Godot Image(散文件路径用;res:// 里的 .dds 交 Godot 原生导入)
m2_material.{h,cpp} texture-stage 描述 → ShaderMaterialM2.5 m2_material.{h,cpp} texture-stage 描述 → ShaderMaterialM2.5
libgr2/ submodule → ../xrender-poc/libgr2 (libgr2 / formats 在仓库根,顶层 CMake add_subdirectory 进来)
formats/ submodule → ../xrender-poc/formatsmsa/msm
SConstruct / CMakeLists godot-cpp 构建(Phase 1 只出 macOS arm64 SConstruct / CMakeLists godot-cpp 构建(Phase 1 只出 macOS arm64
project/ Godot 4.7.1 demo 工程 project/ Godot 4.7.1 demo 工程
main.tscn orbit 相机 + DirectionalLight3D(+shadow) + WorldEnvironment(sky) + Metin2Model main.tscn orbit 相机 + DirectionalLight3D(+shadow) + WorldEnvironment(sky) + Metin2Model
@@ -93,7 +95,7 @@ mtgodot-poc/
export_presets.cfg macOSPhase 1 export_presets.cfg macOSPhase 1
test/ test/
golden/ godot-macos-*.png golden/ godot-macos-*.png
compare.py 与 ../xrender-poc bgfx demo / oracle 对拍 compare.py 与 test/bgfx-reference/ 参考截图 + oracle 对拍
docs/ docs/
GODOT-POC-PLAN.md 本文件 GODOT-POC-PLAN.md 本文件
steps/ 按需拆 steps/ 按需拆
@@ -122,16 +124,16 @@ mtgodot-poc/
| 项 | 选择 | 理由 | | 项 | 选择 | 理由 |
|---|---|---| |---|---|---|
| 引擎版本 | **Godot 4.7.1 stable**(本机已装),锁死不随手升 | GDExtension ABI 绑 minor;升级作独立任务 | | 引擎版本 | **Godot 4.7.1 stable**(本机已装),锁死不随手升 | GDExtension ABI 绑 minor;升级作独立任务 |
| 扩展机制 | `godot-cpp` **`4.7` 分支**GDExtension(非模块编译) | 用官方导出模板,不自定义 Godot 构建 | | 扩展机制 | `godot-cpp` **`101ae38034304346a46ea9ea84ae156d3e860496`**GDExtension(非模块编译) | gitlink 精确锁定,目标 API 4.7;升级作独立任务 |
| 构建工具 | `godot-cpp`**CMake 路径**(本机已有 cmake;不装 SCons)或 `brew install scons` 二选一 | Phase 1 只出 macOS arm64,摩擦最小 | | 构建工具 | `godot-cpp`**CMake 路径**(本机已有 cmake;不装 SCons)或 `brew install scons` 二选一 | Phase 1 只出 macOS arm64,摩擦最小 |
| gr2 解析 | `xrender-poc/libgr2` 静态库嵌入 extension | 与 bgfx route 同一份,已过 9166 fuzz + oracle | | gr2 解析 | `xrender-poc/libgr2` 静态库嵌入 extension | 与 bgfx route 同一份,已过 9166 fuzz + oracle |
| 动画方案 | **B(每帧写骨骼)**A(烘 `Animation`**决策推迟到 Phase 2** | B 直接复用 `libgr2` 采样、最保真;A 的动机(多角色 CPU)在 Mac 上压不出来 | | 动画方案 | **B(每帧写骨骼)**A(烘 `Animation`**决策推迟到 Phase 2** | B 直接复用 `libgr2` 采样、最保真;A 的动机(多角色 CPU)在 Mac 上压不出来 |
| 骨骼权重 | `ArrayMesh` 4 权重优先;>4 影响则 `ARRAY_FLAG_USE_8_BONE_WEIGHTS` | M1 T1.2 早验,三种骨架都查 | | 骨骼权重 | `ArrayMesh` 4 权重优先;>4 影响则 `ARRAY_FLAG_USE_8_BONE_WEIGHTS` | M1 T1.2 早验,三种骨架都查 |
| 贴图 | 见 §02 实操约定(`.dds` 原生导入 vs 运行时 `dds_loader`T1.3 定) | 两条都留 | | 贴图 | 见 §02 实操约定(`.dds` 原生导入 vs 运行时 `dds_loader`T1.3 定) | 两条都留 |
| 材质 | **自定义 `ShaderMaterial`**`light()` 自定义着色贴 Metin2 观感);M1 先 `StandardMaterial3D` 上屏,M2.5 换 | 多 stage 只做近似,列差距清单 | | 材质 | **自定义 `ShaderMaterial`**`light()` 自定义着色贴 Metin2 观感);M1 先 `StandardMaterial3D` 上屏,M2.5 换 | 多 stage 只做近似,列差距清单 |
| 渲染器 | Forward+macOS | Phase 1 不涉及 Compatibility | | 渲染器 | Forward+ / Mobile renderer | 三台目标设备(Mac / 一加 13 / iPhone 16)都是现代硬件,全程不涉及 Compatibility(GLES3) |
| 阴影 / sky | Godot 内置 `DirectionalLight3D` shadow + `WorldEnvironment` procedural skyM2.5 接入 | 成本近零,Mac 观感必要 | | 阴影 / sky | Godot 内置 `DirectionalLight3D` shadow + `WorldEnvironment` procedural skyM2.5 接入 | 成本近零,Mac 观感必要 |
| 资产来源 | 从 `m2dev-client-main/assets` **warrior + 另 2 种骨架** 的 gr2/dds 到 `project/assets/`,散文件 | 不接 eterpackPhase 2 / M4 | | 资产来源 | 从 `assets/` **warrior + 另 2 种骨架** 的 gr2/dds 到 `project/assets/`,散文件 | 不接 eterpackPhase 2 / M4 |
| 真值源 | `xrender-poc/oracle` golden + bgfx demo 同机位 / pose / t 截图 | 复用现成对拍基建 | | 真值源 | `xrender-poc/oracle` golden + bgfx demo 同机位 / pose / t 截图 | 复用现成对拍基建 |
| 仓库 | 新建 `mtgodot-poc/``libgr2` / `formats` submodule 指向 `xrender-poc` | 隔离,不污染 bgfx route | | 仓库 | 新建 `mtgodot-poc/``libgr2` / `formats` submodule 指向 `xrender-poc` | 隔离,不污染 bgfx route |
@@ -210,9 +212,9 @@ mtgodot-poc/
--- ---
### M3 ·(Phase 2,本轮不做)iOS + Android + 性能 ### M3 ·(Phase 2,本轮不做)Android + iOS bring-up + 性能
移动端真机、三端一致、性能、生命周期、Compatibility 渲染器、每帧写骨骼的 A/B 决策 —— **go / no-go 结论在此产生**。详见后续 `steps/M3-mobile.md`(届时再写)。 一加 13Vulkan+ iPhone 16Metal)真机跑起来、三端一致、性能留档、生命周期。iOS 需把 GDExtension 按 arm64 **静态库**编出来(链进 app 二进制)。不涉及 Compatibility 渲染器。详见后续 `steps/M3-mobile.md`(届时再写)。
### M4 ·(Phase 2 可选拉伸) ### M4 ·(Phase 2 可选拉伸)
@@ -244,7 +246,7 @@ mtgodot-poc/
| `.gr2` 被 Godot import 流程扫到报错 | 打开工程时一堆 import 错误 | `project/assets/.gdignore``.gr2` 走运行时绝对路径 / `user://`T1 前定死 | | `.gr2` 被 Godot import 流程扫到报错 | 打开工程时一堆 import 错误 | `project/assets/.gdignore``.gr2` 走运行时绝对路径 / `user://`T1 前定死 |
| gr2 basis / 单位换算到 Godot(左手 → 右手 Y-up + 缩放) | M1 模型躺 / 镜像 / 骨骼反向 / 大小离谱 | `gr2_bridge` 一次性处理,定 `M2_TO_GODOT`,用朝向明确资产手校,写注释 | | gr2 basis / 单位换算到 Godot(左手 → 右手 Y-up + 缩放) | M1 模型躺 / 镜像 / 骨骼反向 / 大小离谱 | `gr2_bridge` 一次性处理,定 `M2_TO_GODOT`,用朝向明确资产手校,写注释 |
| `Skeleton3D` 骨骼数上限 / 8 权重路径行为 | M1 T1.2 蒙皮爆开 / 部位错位 | 三种骨架都查 `gr2dump`>4 权重走 8-bone flag | | `Skeleton3D` 骨骼数上限 / 8 权重路径行为 | M1 T1.2 蒙皮爆开 / 部位错位 | 三种骨架都查 `gr2dump`>4 权重走 8-bone flag |
| **多 stage 固定管线材质无法在 `ShaderMaterial` 近似到可接受** | M2.5 T2.5.2/3 关键装备观感明显错 | **这是 Phase 1 主要方案性风险**;差距清单区分"可延期"vs"死路",后者触发转回 bgfx route | | **多 stage 固定管线材质无法在 `ShaderMaterial` 近似到可接受** | M2.5 T2.5.2/3 关键装备观感明显错 | **这是 Phase 1 主要方案性风险**;差距清单区分"可延期"vs"死路"。中期评审已判定为可延期(观感打磨,见 `MIDREVIEW.md`);bgfx route 已停,无「转回」分支 |
| 半透明 / additive 排序 corner case | M2.5 T2.5.3 发光部件穿插 | 记入差距清单;Godot 的 `render_priority` / `depth_draw` 调,不追求 100% | | 半透明 / additive 排序 corner case | M2.5 T2.5.3 发光部件穿插 | 记入差距清单;Godot 的 `render_priority` / `depth_draw` 调,不追求 100% |
| motion event 语义(`.msa`/`.msm` 帧事件类型) | M2 T2.3 事件类型没覆盖全 | Phase 1 只需打点回调,实际特效 Phase 2;未覆盖类型列清单 | | motion event 语义(`.msa`/`.msm` 帧事件类型) | M2 T2.3 事件类型没覆盖全 | Phase 1 只需打点回调,实际特效 Phase 2;未覆盖类型列清单 |
@@ -252,11 +254,11 @@ mtgodot-poc/
| 风险 | 缓解 / 退路 | | 风险 | 缓解 / 退路 |
|---|---| |---|---|
| 每帧 GDExtension 写骨骼的 CPU 成本(多角色) | M2 T2.7 已留档;转 A 方案(烘 `Animation` + `AnimationMixer` | | 每帧 CPU LBS 全量重建 mesh 的成本(多角色) | `MTGODOT_GPUSKIN=1` 自写蒙皮着色器(已实现);多线程构建 + eterpack 共享资产 |
| Godot Compatibility(GLES3) 老安卓 shader 兼容 | M2.5 的 `ShaderMaterial` 写保守,避开 Compatibility 不支持特性 | | 自写蒙皮着色器(`texelFetch` + RGBAF + `BONE_INDICES`)在 iPhone 16 Metal / 一加 13 Vulkan 上的行为 | Phase 2 各跑一次;出问题回落 CPU LBS 或改 uniform 数组传骨骼。三台都是现代设备,无 GLES3 约束 |
| Godot 节点开销(大场景规模) | `MultiMesh` / `RenderingServer` 直调兜底 | | Godot 节点开销(大场景规模) | `MultiMesh` / `RenderingServer` 直调兜底 |
| GDExtension ABI 绑 Godot minor | 锁 4.7.1,升级作独立任务 | | GDExtension ABI 绑 Godot minor | 锁 4.7.1,升级作独立任务 |
| iOS 交叉编译 `libgr2` 静态库 | 提前出 iOS `.a` 预编译产物 | | iOSGDExtension 须静态链接进 app 二进制(`libgr2` 出 iOS arm64 `.a`),签名 / 模板脾气大 | 自用免费 provisioning 够用;预留 23 天 bring-up |
--- ---
@@ -286,25 +288,28 @@ mtgodot-poc/
| 产物 | | 产物 |
|---| |---|
| iOS `.app` + Android APK/AAB + 真机性能 / 生命周期报告 | | iOS `.ipa`iPhone 16+ Android APK(一加 13+ 两台真机性能 / 生命周期报告 |
| **go/no-go 对比结论(一页纸)**:与 bgfx route 在工作量 / 性能 / 视觉保真度 / 长期维护四轴对比,给出客户端跨平台基座选型建议 | | 三设备 bring-up 报告:三端渲染一致性截图 + 各设备帧率留档(自用参考,不做对外选型结论) |
--- ---
## 选型变更记录 ## 选型变更记录
- 2026-08-29 · **设备范围定死(自用)**:目标 = MacmacOS / Metal+ 一加 13Android arm64 / Vulkan+ iPhone 16iOS arm64 / Metal),各一台。取消对外 go/no-go 门禁(bgfx route 已停、项目自用);Phase 2 收敛为「三设备各一次 bring-up + 性能留档」。**全程不涉及 GLES3 / Compatibility 渲染器**,不做中低端 / 老机器。SHINSOO 的 W0→W1+ 因此无外部门禁。
- 2026-08-29 · 范围收敛为 **Phase 1 = macOS only**;新增 **M2.5 材质与观感保真** 作为 Phase 1 核心门禁;动画 A/B 决策推迟到 Phase 2Godot 版本钉死 4.7.1 / godot-cpp 4.7。 - 2026-08-29 · 范围收敛为 **Phase 1 = macOS only**;新增 **M2.5 材质与观感保真** 作为 Phase 1 核心门禁;动画 A/B 决策推迟到 Phase 2Godot 版本钉死 4.7.1 / godot-cpp 4.7。
- 2026-08-29 · **M1 T1.3**:贴图走 **运行时 `dds_loader`**`src/dxt.cpp`,端口自 xrender-poc)。理由:Godot 4 的 `Image.load()` 不支持 `.dds`,且资产从 `res://` 外的绝对路径加载(`.gr2``.gdignore` 同理)。DXT1/3/5 + BGRA8level 0 + `generate_mipmaps()` - 2026-08-29 · **M1 T1.3**:贴图走 **运行时 `dds_loader`**`src/dxt.cpp`,端口自 xrender-poc)。理由:Godot 4 的 `Image.load()` 不支持 `.dds`,且资产从 `res://` 外的绝对路径加载(`.gr2``.gdignore` 同理)。DXT1/3/5 + BGRA8level 0 + `generate_mipmaps()`
- 2026-08-29 · **动画方案**确认走 **B 方案** —— `_process``gr2::sample_pose``Skeleton3D.set_bone_global_pose(i, gr2_to_godot(world[i]))`Godot GPU 蒙皮 `skin_matrix = global_pose(i) * bind_pose(i)` 恒等于 gr2 deformer 矩阵(`gr2_bridge.h` 有推导)。A 方案(烘 `Animation`)仍推迟到 Phase 2 按多角色 CPU 采样决定 - 2026-08-29 · **动画方案**B 方案(每帧 `gr2::sample_pose` 驱动)。**两条蒙皮路径**,都用 libgr2 的 `skin` 矩阵完整仿射(shear 保留):默认 **CPU LBS**`metin2_anim.cpp``Metin2Model::cpu_skin`,每帧重建 `ArrayMesh`);`MTGODOT_GPUSKIN=1`**自写蒙皮顶点着色器**`m2_material` `SRC_SKIN`,逐骨 4×3 矩阵存 RGBAF 纹理,`Metin2Model::gpu_skin` 每帧更新,绕过 `Skeleton3D`)。原因:Godot 内置 GPU 蒙皮 / `set_bone_pose` 会正交化骨骼矩阵、**丢掉 Granny 烘在 rig 骨(`bone_front_*`/`Bone_shoulder_*`warrior 7 / sura 16)上的 shear**`tools/anim_probe` 定位,非 libgr2 —— `world[]` 对拍 Granny ≤4.1e-5)。旧 `Skeleton3D::set_bone_pose` 路径已删。A 方案(烘 `Animation`)走 `Skeleton3D` 骨骼、同样丢 shear,不用
- 2026-08-29 · **坐标/单位**`gr2_to_godot()` = gr2 行主序 Mat4 的 4×4 转置 → Godot `Transform3D`Z-up cm → Y-up m`rotate(-90°,X)` + `scale(0.01)`)放在 `Metin2Model` 节点自身 transform`make_conv`)。warrior/warrior_lord/assassin/shaman_lord 实测 `flip_z=false``flip_winding=false``unit_scale=0.01` 正立、朝 +Z、无镜像。 - 2026-08-29 · **坐标/单位**`gr2_to_godot()` = gr2 行主序 Mat4 的 4×4 转置 → Godot `Transform3D`Z-up cm → Y-up m`rotate(-90°,X)` + `scale(0.01)`)放在 `Metin2Model` 节点自身 transform`make_conv`)。warrior/warrior_lord/assassin/shaman_lord 实测 `flip_z=false``flip_winding=false``unit_scale=0.01` 正立、朝 +Z、无镜像。
### M2.5 T2.5.6 — 材质无法 1:1 还原 / 待办项清单 ### M2.5 T2.5.6 — 材质无法 1:1 还原 / 待办项清单
> **不再单独维护** —— 全部并入 [`BACKLOG.md`](./BACKLOG.md) B 段(`B1``B11`)。下表为历史快照。
| 项 | 现状 | 影响 | 出路 | | 项 | 现状 | 影响 | 出路 |
|---|---|---|---| |---|---|---|---|
| **libgr2 POD API 不暴露材质数据** —— 只有 `material_count`/`texture_count`,无材质名、无贴图文件名、无 texture-stage op、无 `D3DBLEND_*`/alpha-test 标志 | 贴图靠**文件名约定 + 目录大小写不敏感扫描**(`_resolve_texture`);blend 靠**表面名关键字启发式**(`guess_blend`hair→alpha-test、cape/skirt→alpha、effect/glow→add,其余 opaque | warrior 5/5、assassin 17/17、shaman 1/1 表面命中贴图,但**多材质槽 → 具体贴图/混合的精确映射做不到**;换皮/多贴图变体、精确 alpha-test 阈值、MODULATE2X/ADDSIGNED 等 stage op 无法复现 | **需要给 libgr2 加材质 API**(新文件 `gr2_material.cpp`,走类型树取 `Materials[].Name` + `Maps[].Texture.FromFileName` + shader/blend hint)。这也正是 xrender-poc M1 T5「多 stage 材质对拍待 oracle」的同一缺口,两条 route 共用修法。 | | **libgr2 POD API 不暴露材质数据** —— 只有 `material_count`/`texture_count`,无材质名、无贴图文件名、无 texture-stage op、无 `D3DBLEND_*`/alpha-test 标志 | 贴图靠**文件名约定 + 目录大小写不敏感扫描**(`_resolve_texture`);blend 靠**表面名关键字启发式**(`guess_blend`hair→alpha-test、cape/skirt→alpha、effect/glow→add,其余 opaque | warrior 5/5、assassin 17/17、shaman 1/1 表面命中贴图,但**多材质槽 → 具体贴图/混合的精确映射做不到**;换皮/多贴图变体、精确 alpha-test 阈值、MODULATE2X/ADDSIGNED 等 stage op 无法复现 | **需要给 libgr2 加材质 API**(新文件 `gr2_material.cpp`,走类型树取 `Materials[].Name` + `Maps[].Texture.FromFileName` + shader/blend hint)。这也正是 xrender-poc M1 T5「多 stage 材质对拍待 oracle」的同一缺口,两条 route 共用修法。 |
| **顶点色** | `gr2::Vertex` POD 无 color 字段;shader 已留 `COLOR` 通道,默认白 | Metin2 PC 模型基本不用顶点色,影响小;特效/地形会用 | libgr2 若暴露则 `build_mesh``ARRAY_COLOR` | | **顶点色** | `gr2::Vertex` POD 无 color 字段;shader 已留 `COLOR` 通道,默认白 | Metin2 PC 模型基本不用顶点色,影响小;特效/地形会用 | libgr2 若暴露则 `build_mesh``ARRAY_COLOR` |
| **`TriGroup.material_index` 未接线** | libgr2 已解析每个三角组的 material_index,但 `build_mesh` 目前一个 gr2 mesh = 一个 surface(未按 material_index 再拆分) | 单 mesh 内多材质的模型会用同一张贴图 | `build_mesh``tri_groups` 的 material_index 分 surface(有材质 API 后一起做) | | ~~`TriGroup.material_index` 未接线~~ **已做(2026-08-29** | `gr2_bridge::build_parts()` 一个 gr2 mesh `tri_groups` 拆成 N 个 `RenderPart` → N 个 Godot surface,贴图取 `Mesh::material_textures[matidx]`mesh-local,非全局 `Materials[]`)。GPU 和 CPU 蒙皮路径都走 `RenderPart`。 | — |
| **ShaderMaterial 静态 `Shader` 退出泄漏** | `shader_for()` 用 function-static `Ref<Shader>`Godot 退出时报 "1 shader never freed" | 仅退出时一条 warning,无功能影响 | 改成每材质自持 Shader,或注册 cleanup | | **ShaderMaterial 静态 `Shader` 退出泄漏** | `shader_for()` 用 function-static `Ref<Shader>`Godot 退出时报 "1 shader never freed" | 仅退出时一条 warning,无功能影响 | 改成每材质自持 Shader,或注册 cleanup |
| **alpha 排序 corner case** | 半透明/additive 用 `render_priority` 粗排,未做逐三角/OIT | 多层半透明部件可能穿插 | 记录,不追求 100%Phase 2 M2.5 打磨) | | **alpha 排序 corner case** | 半透明/additive 用 `render_priority` 粗排,未做逐三角/OIT | 多层半透明部件可能穿插 | 记录,不追求 100%Phase 2 M2.5 打磨) |
| **法线未做逆转置** | 走 Godot GPU 蒙皮,其法线处理与 Granny runtime 的「上 3×3 不逆转置」近似不完全一致 | 非均匀缩放骨骼下法线略有差异;warrior 类资产无明显问题 | Phase 2 逐帧对拍时量化 | | **法线变换** | CPU 与 GPU`SRC_SKIN` 着色器)两条路径都用混合矩阵的**上 3×3、不逆转置** —— 与 Granny runtime 一致(见 `docs/reference/steps/M2-anim-skinning.md`)。非均匀缩放骨骼下这是 Granny 自己的近似,我们与之一致。 | 已一致 | — |
+45 -29
View File
@@ -1,7 +1,8 @@
# Phase 1 中期评审 — mtgodot-poc # Phase 1 中期评审 — mtgodot-poc
> 日期:2026-08-29 · 平台:macOSApple M2)· Godot 4.7.1 · 对标 [`GODOT-POC-PLAN.md`](./GODOT-POC-PLAN.md) §00 Phase 1 > 日期:2026-08-29(评审后又做了一轮收尾,见 §4/§5 的「已做」标注)· 平台:macOSApple M2)· Godot 4.7.1
> 结论先行:**桥接层工程量小、风险已基本出清,建议进入 Phase 2(移动端)**。M2.5 门禁的"材质精确映射"仍有一处需要 oracle 的开口,不阻塞。 > · 对标 [`GODOT-POC-PLAN.md`](./GODOT-POC-PLAN.md) §00 Phase 1
> 结论先行:**桥接层工程量小、风险已基本出清,建议进入 Phase 2(一加 13 / iPhone 16 三设备 bring-up,自用)**。M2.5 材质门禁已闭(贴图走 gr2 material 绑定 + 逐 tri_group 拆 surface);仅剩逐材质 oracle 纸面对拍这一可选项。
--- ---
@@ -11,54 +12,69 @@
|---|---|---| |---|---|---|
| **M0'** 脚手架 | ✅ | GDExtension 加载、`Metin2Model`/`Metin2AnimPlayer` 注册、`libgr2` 静态链接、Godot 4.7 工程 Metal Forward+ 出画 | | **M0'** 脚手架 | ✅ | GDExtension 加载、`Metin2Model`/`Metin2AnimPlayer` 注册、`libgr2` 静态链接、Godot 4.7 工程 Metal Forward+ 出画 |
| **M1** 静态渲染 | ✅ | `warrior_cheongrin`75 骨 / 5 表面 / 3324 顶点,正立、朝 +Z、`unit_scale=0.01``flip_z/flip_winding=false`、贴图正确(`test/golden/m1-bindpose.png`)。多骨架:`warrior_lord`(v6,74)、`assassin`(v6,95,17 表面)、`shaman_lord`(**v7**,93) 均加载渲染正确 | | **M1** 静态渲染 | ✅ | `warrior_cheongrin`75 骨 / 5 表面 / 3324 顶点,正立、朝 +Z、`unit_scale=0.01``flip_z/flip_winding=false`、贴图正确(`test/golden/m1-bindpose.png`)。多骨架:`warrior_lord`(v6,74)、`assassin`(v6,95,17 表面)、`shaman_lord`(**v7**,93) 均加载渲染正确 |
| **M2** 骨骼动画 | ✅(渲染路径)/ ⚠️(一条 upstream bug | `general/wait`2.67s / 75 track)经 `gr2::sample_pose`(跨文件)→ 逐骨 Godot **局部 pose**`set_bone_pose`)→ GPU 蒙皮,头 / 护甲对齐正确(`test/golden/m2-wait.png`)。**数值验证**`get_bone_global_pose(i)` vs `conv(world[i])` 全 75 骨 ≤ **2.6e-5**CPU-LBS 参考路径(`MTGODOT_CPUSKIN=1`,与 xrender `skin_mesh` 同式)与 GPU 蒙皮同结果。`selfcheck` 0 NaN。**但**`dance_1` 等表情动作会让头 / 颈塌陷 —— **libgr2 曲线解码 bug,在 xrender-poc bgfx demo 里同样复现**见 §4 | | **M2** 骨骼动画 | ✅ | `gr2::sample_pose`(跨文件)→ 蒙皮,**两条路径都用 libgr2 的 `skin` 矩阵完整仿射(shear 保留)**:默认 **CPU LBS**(每帧重建 mesh);`MTGODOT_GPUSKIN=1` 走**自写蒙皮顶点着色器**`m2_material` `SRC_SKIN`,逐骨 4×3 矩阵存 RGBAF 纹理,绕过 `Skeleton3D`)。`dance_1` 两条路径头/护甲/躯干都正确、与 xrender-poc bgfx demo 一致。`selfcheck` 0 NaN。~~旧 `Skeleton3D::set_bone_pose` 路径~~ 已删(Godot 内置蒙皮正交化丢 shear见 §4 |
| **M2.5** 材质与观感 | 🟡 门禁未闭 | `ShaderMaterial`modulate tex×COLOR×lightopaque/alpha/alpha-test/add 四模式)+ 目录扫描贴图解析(warrior 5/5、assassin 17/17、shaman 1/1 表面命中)+ 方向光阴影 + procedural sky + 引擎雾(`test/golden/m25-*.png`)。`libgr2::dump_materials()` 已加(见 §4 | | **M2.5** 材质与观感 | 门禁已闭(材质精确映射);观感打磨留 Phase 2 | 4 个 `ShaderMaterial` shader`SRC_MIX`opaque / alpha / alpha-test)· `SRC_SKIN`(同 + GPU 蒙皮 vertex)· `SRC_ADD`additive)· `SRC_ADD_SKIN`。贴图 = **gr2 material 绑定**`Mesh::material_textures[matidx]`mesh-local)→ 找不到才 `_resolve_texture` 文件名启发式;`build_parts()``tri_group.material_index` 把一个 gr2 mesh 拆成 N 个 surfaceshaman 1→2、assassin 17→18、warrior 5→5,实测各面贴图正确)。blend/cull/alpha 靠 `guess_blend()` 按表面名启发式(effect→add、hair→alpha-test、cloak/cape/skirt→alpha)。方向光阴影 + procedural sky + `MTGODOT_FOG=1` 引擎雾(`test/golden/m25-*.png`)。 |
## 2. 桥接层工程量(实测) ## 2. 桥接层工程量(实测)
新增 C++**1100**(含端口自 xrender-poc 的 DXT 解码 160 行) `extension/src`**1997**不含 godot-cpp含端口自 xrender-poc 的 DXT 解码 ~180 行)
中期评审时约 1100 行,评审后收尾又加了 ~700(`build_parts` 拆 surface、`SRC_SKIN` GPU 蒙皮着色器、
`.msa`/`.msm` 解析、路径解析、逐 surface 材质缓存)。
| 文件 | 行 | 职责 | | 文件 | 行 | 职责 |
|---|---|---| |---|---|---|
| `gr2_bridge.{h,cpp}` | ~260 | gr2 Mat4→`Transform3D`4×4 转置)、`build_skeleton`/`build_skin`/`build_mesh`、Z-up→Y-up `make_conv` | | `metin2_model.{h,cpp}` | ~870 | `Metin2Model` 节点、`.gr2`/`.msm` 加载、`build_parts` 拆 surface、贴图解析、材质、CPU/GPU 蒙皮、属性 |
| `metin2_model.{h,cpp}` | ~470 | `Metin2Model` 节点、`load_gr2`、贴图解析、材质、属性 | | `metin2_anim.{h,cpp}` | ~420 | `Metin2AnimPlayer`、每帧 `sample_pose` → CPU/GPU 蒙皮、`.msa` 解析 + motion event 派发、`selfcheck` |
| `metin2_anim.{h,cpp}` | ~230 | `Metin2AnimPlayer`、每帧 `sample_pose`→骨骼、`selfcheck` | | `gr2_bridge.{h,cpp}` | ~290 | gr2 Mat4→`Transform3D`4×4 转置)、`build_skeleton`/`build_skin``build_parts`+`build_mesh`、Z-up→Y-up `make_conv` |
| `m2_material.{h,cpp}` | ~130 | Metin2 风格 `ShaderMaterial`(两个 shaderblend_mix / blend_add | | `m2_material.{h,cpp}` | ~235 | 4 个 `ShaderMaterial` shader`SRC_MIX` / `SRC_SKIN` / `SRC_ADD` / `SRC_ADD_SKIN`+ 退出前 `cleanup_material_shaders` |
| `dxt.{h,cpp}` | ~160 | DDS DXT1/3/5 软解(端口) | | `dxt.{h,cpp}` | ~180 | DDS DXT1/3/5 软解(端口level 0 |
| libgr2 `gr2_material.cpp`xrender-poc | ~110 | `dump_materials()` 类型树遍历(附加) | | `register_types.{h,cpp}` | ~40 | GDExtension 入口 |
| libgr2vendored)新增 `gr2_material.cpp` + `Mesh::material_textures` | ~150 | `dump_materials()` + 逐 mesh MaterialBindings→贴图名 |
**耗时**M0'M2.5 一次会话跑通。与计划 §05 估的 Phase 1「3.55 周单人」相比**核心桥接远比预估轻** —— 因为 `libgr2` 已完成、Godot 的 `Skeleton3D`/`ArrayMesh`/`Skin`/GPU 蒙皮直接可用,且关键恒等式(`skin_matrix = global_pose(i)·bind_pose(i) == gr2 deformer 矩阵`)成立,不需要自研蒙皮。剩余预估工作量集中在 M2.5 材质精修 + 对拍。 **耗时**M0'M2.5 一次会话跑通,评审后收尾一次会话。与计划 §05 估的「3.5–5 周单人」相比 **核心桥接远比预估轻** ——
`libgr2` 已完成、Godot 的 `Skeleton3D`/`ArrayMesh`/`Skin` 直接可用;蒙皮因 Godot 内置路径丢 shear(见 §4
改为自己做(CPU LBS 默认 + 自写顶点着色器),也只是 ~200 行。
## 3. 关键技术结论 ## 3. 关键技术结论
- **坐标/单位**gr2 行主序 Mat4 → Godot `Transform3D` 就是一次 **4×4 转置**gr2 平移在第 4 行 = Godot `.origin`)。Z-up cm → Y-up m`rotate(-90°,X)` + `scale(0.01)`)放在 `Metin2Model` 节点自身 transform。四个测试骨架全部 `flip_z=false``flip_winding=false` 正确 —— **Metin2/Granny 内容对 Godot 右手系不需要额外镜像** - **坐标/单位**gr2 行主序 Mat4 → Godot `Transform3D` 就是一次 **4×4 转置**gr2 平移在第 4 行 = Godot `.origin`)。Z-up cm → Y-up m`rotate(-90°,X)` + `scale(0.01)`)放在 `Metin2Model` 节点自身 transform。四个测试骨架全部 `flip_z=false``flip_winding=false` 正确 —— **Metin2/Granny 内容对 Godot 右手系不需要额外镜像**
- **动画走 B 方案**`_process``sample_pose``world[]``skel.set_bone_global_pose(i, conv(world[i]))`Skin bind = `conv(inverse_world[i])`。Godot 的 GPU 蒙皮矩阵 `global_pose(i)·bind(i)` 恒等于 gr2 deformer 矩阵。**不需要烘焙 `Animation` 资源** 即可保真(A 方案仅在移动端多角色 CPU 压不住时才需要)。 - **动画走 B 方案 + 自做蒙皮**`_process``sample_pose``world[]` / `skin[]``skin[i] = inverse_world[i]·world[i]`)。**Godot 内置蒙皮(`Skeleton3D` + `set_bone_pose`)不能用** —— 它把每骨 transform 分解成 position + 单位四元数 + 逐轴 scale,丢掉 Granny 烘在 rig 骨上的 shear(§4)。改为:默认 **CPU LBS**`Metin2Model::cpu_skin`,每帧把 `skin[]` 逐顶点作用、重建 `ArrayMesh`);`MTGODOT_GPUSKIN=1` 走**自写蒙皮顶点着色器**`SRC_SKIN`,逐骨完整 4×3 矩阵存 RGBAF 纹理)。`set_bone_global_pose` / `Skeleton3D` 蒙皮路径已删。不烘 `Animation` 资源(A 方案走 `Skeleton3D` 骨骼、同样丢 shear,不用)。
- **贴图**Godot 4 `Image.load()` 不支持 `.dds` → 运行时软解 DXT`dxt.cpp`)。`.gr2``.gdignore` + 绝对路径加载,绕开 Godot import。 - **贴图**Godot 4 `Image.load()` 不支持 `.dds` → 运行时软解 DXT`dxt.cpp`level 0 + `generate_mipmaps()`)。`.gr2``.gdignore` + 绝对路径加载,绕开 Godot import。
- **移动端预判更好**M2.5 的 `ShaderMaterial` + `ArrayMesh` + `Skeleton3D` 都在 Godot Compatibility(GLES3) 支持范围内 —— Phase 2 老安卓机兜底比"Godot 里塞自研渲染器"那条路好 - **移动端预判**目标设备定为 MacMetal+ 一加 13Vulkan+ iPhone 16Metal),均现代硬件,**不涉及 Compatibility(GLES3)**。CPU LBS 路径(默认)+ `ShaderMaterial` + `ArrayMesh` 无平台特性风险。自写蒙皮着色器用了 `texelFetch` + RGBAF 纹理 + `BONE_INDICES` —— Vulkan / Metal 都支持,Phase 2 在一加 13 / iPhone 16 各跑一次确认(出问题则人群场景回落 CPU 或改 uniform 数组传骨骼)
## 4. 剩余风险 / 门禁开口 ## 4. 剩余风险 / 门禁开口
| 项 | 影响 | 出路 | | 项 | 影响 | 出路 |
|---|---|---| |---|---|---|
| **libgr2 曲线解码 bug —— 部分动画头 / 颈塌陷**`dance_1` 等表情,`rot:Old(d=2,dim=4)` 二次四元数 B 样条)。**这不是 Godot route 的问题** —— 已用 xrender-poc 的 bgfx demo 在同一动画 / 同一 t 复现完全一致的塌陷(`/tmp/xr-*.png`),且 mtgodot 的 CPU-LBS 参考路径(与 xrender 同式)也复现。`wait` / `run` 等动画正常。xrender-poc M2「对拍 Granny ≤4.6e-5」的 23 用例未覆盖到这个可视化用例。 | 表情动作不可用;idle / 移动动画正常。当前 demo 默认 `general/wait` | libgr2 `gr2_anim.cpp``Curve::eval` degree-2 分支 vs Granny `OldCurve` B 样条基对拍修正(需 Granny oracle)。两条 route 共用修法。**属 xrender-poc/libgr2 的活,不阻塞 Godot route 结论** | | ~~libgr2 曲线解码 bug —— 头 / 颈塌陷~~ **→ 已定位并修复(2026-08-29,误判)**。不是 libgr2`tools/anim_probe` 全时间轴扫 `dance_1` 240 帧,libgr2 的 `world[]` 全部良态(0 奇异 / 0 NaN,Head/Neck 是干净刚体),且 `oracle/run-diff-suite.sh` 已对拍真 Granny @ t=0/4.7/11.3/20 ≤4.1e-5(含 `d=2` 四元数曲线)。真因:**Granny 给每个 Metin2 模型的 ~7 根骨(`bone_front_01..04``Bone_shoulder_01..04` —— 前襟条 + 护肩片)烘了 shear + 非均匀缩放**world det 低到 0.065),而 Godot `Skeleton3D::set_bone_pose` 把 transform 分解成 position + 单位四元数 + 逐轴 scale**shear 被静默丢弃**TRS 往返误差 0.07–0.94)→ 那几根骨塌。`wait` 同样有这 7 根坏骨,只是动得少不明显(不是 `dance_1` 专属)。 | **已消除**:① CPU 线性混合蒙皮转默认路径(`Metin2Model::cpu_skin`),直接把 libgr2 的 `skin[]` 逐顶点作用、绕过 `Skeleton3D`;② `MTGODOT_GPUSKIN=1` 走自写蒙皮顶点着色器(`SRC_SKIN` / `SRC_ADD_SKIN`,逐骨完整 4×3 矩阵存 RGBAF 纹理),不正交化、shear 保留。旧 `set_bone_pose` 路径已删。headless 对拍:`dance_1` @ t=7/19 两条路径头/颈/躯干与 xrender-poc bgfx demo 一致。 | 已完成。代价:CPU 路径每帧变顶点 + 重建 mesh(人群切 GPU 着色器路径)。`anim_probe` 扫全 racewarrior 7 根、**sura 16 根** shear 骨(`test/shear-bones-survey.md`),故自写着色器是必需、非优化。 |
| **M2.5 材质精确映射未闭**M2.5 门禁开口)| `libgr2::dump_materials()` 已能取材质名 + `FromFileName`warrior 4 材质 / 2 有 diffuse 名),但 material_index→贴图 的映射**未对拍 Granny**,实测驱动 warrior 时 body 表面误取 face 贴图。当前默认走文件名+目录扫描启发式(观感正确),material 路径 `use_gr2_materials` 开关默认关 | 需要 Granny oracle 逐材质对拍(与 xrender-poc M1 T5 同一缺口,共用修法)。这是 Phase 1 收尾的最后一项 | | ~~M2.5 材质精确映射未闭~~ **→ 基本闭合(2026-08-29**。`tri_group.material_index`**mesh-local** 索引(进 `Mesh::MaterialBindings`),不是全局 `Materials[]` —— 原来用 `dump_materials()[matidx]` 才把 body/face 搞混。改用 libgr2 的 `Mesh::material_textures[matidx]`(与 `MaterialBindings` 平行、xrender-poc session 里已按渲染结果验过 warrior/sura/assassin/shaman 全对)。`use_gr2_materials` 默认开、文件名启发式降为回退。 | 已消除。剩:跑一次 `oracle/run-diff-suite.sh` 级别的逐材质对拍做纸面确认(可选,观感已对);MODULATE2X/ADDSIGNED 等 stage op 仍只近似 |
| **对拍未接线** | 与 xrender-poc bgfx demo / oracle 的逐帧几何/骨骼矩阵对拍脚本(`test/compare.py`)未写 | 需先构建 xrender-poc bgfx demo(首次 ~10 min)。中期评审后、进 Phase 2 前补 | | ~~多材质槽内拆分~~ **→ 已做**`gr2_bridge.cpp``build_parts()`,一个 gr2 mesh 有 N>1 个 `tri_groups` 就拆成 N 个 Godot surface(各自 material + 索引子区间)。`build_mesh` 和 CPU 蒙皮路径都按 `RenderPart` 走。shaman1 mesh→2 surface)、assassin17→18)实测各面贴图正确。 | 已做 |
| **性能数字被节流** | 后台窗口下 Godot 限 30fps1–50 角色帧时间都贴在 33ms 天花板;100 角色破顶到 52ms。资产构建 **~165ms/角色**(单线程,无跨模型缓存),100 角色加载 16.5s(`test/godot-macos-stress.json`) | 真机 / 前台窗口下重测(Phase 2)。构建成本需 Phase 2 做多线程 + eterpack 共享贴图 | | ~~对拍未接线~~ **→ 已写 `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)。 | 基本完成 |
| **多材质槽内拆分** | `build_mesh` 目前一个 gr2 mesh = 一个 surface,未按 `tri_groups[].material_index` 再拆 | 有 material API 对拍后一起做 | | **性能** | 前台窗口 M2 重测(`test/godot-macos-stress.json`,默认 CPU LBS):1 角色 46fps、10 角色 36fps、30 角色 24fps。CPU 路径瓶颈是每帧 `cpu_mesh` 全量重建(不是 LBS 数学)。构建仍 ~170ms/角色(单线程无缓存)。**GPU 蒙皮着色器已实现**(`MTGODOT_GPUSKIN=1`,每帧只更新一张骨骼纹理),人群场景应切它 —— stress harness 未用 GPU 路径重跑。 | Phase 2GPU 路径跑人群 bench + 多线程构建 + eterpack 共享资产 + 一加 13 / iPhone 16 实测。 |
| ShaderMaterial 静态 Shader 退出泄漏、alpha 排序 corner case、法线未逆转置 | 均为已知小项,见 `GODOT-POC-PLAN.md` §M2.5 T2.5.6 | 不阻塞 | | 剩余小项 | ~~ShaderMaterial 静态 Shader 退出泄漏~~ 已修(`cleanup_material_shaders``register_types` 卸载时调)· alpha 排序 corner case`render_priority` 粗排)· 背面剔除写死 `cull_back`Metin2 有 `CULL_NONE` 面)· `_frame_model` 对披风类框选偏 | 均不阻塞,见 [`BACKLOG.md`](./BACKLOG.md)`A3` `A6` `B10` `E5`)。法线用混合矩阵上 3×3 不逆转置 —— **与 Granny runtime 一致,是正确行为不是风险** |
## 5. 建议 ## 5. 建议
**进入 Phase 2iOS + Android)。** **进入 Phase 2一加 13 / iPhone 16 三设备 bring-up,自用)。**
理由:Phase 1 要证明的三件事里,①gr2→Godot 场景对象、②动画运行时移植,**已用可运行代码 + 截图 + 0-NaN 自检证明可行且工程量小**;③观感还原的基础设施(自定义 shader、阴影、sky、fog、多骨架已就位,只差"材质精确映射"这一处需 oracle 的收尾,不构成方案性风险。 理由:Phase 1 要证明的三件事里,①gr2→Godot 场景对象、②动画运行时移植,**已用可运行代码 + 截图 + 0-NaN 自检证明可行且工程量小**;③观感还原自定义 shader、逐 tri_group 材质、阴影、sky、fog、多骨架已就位,M2.5 门禁(材质精确映射)已闭,剩余是观感打磨(多 stage op / cull / 阴影调优),不构成方案性风险。
Phase 2 前的收尾清单1.52 周) Phase 2 前的收尾清单 —— 大部分已在 2026-08-29 做完
1. `test/compare.py`,与 xrender-poc bgfx demo 同机位 / 同 t 对拍几何 + 骨骼矩阵 1. `test/compare.py` —— oracle gate + 每 case 退出码 / 截图 / `surfaces>0` / `NaN=0` 判据(CPU + GPU 两路)。`compare-report.json`
2. `libgr2::dump_materials` 的 Granny oracle 对拍,闭合 M2.5 门禁 2. ✅ M2.5 材质门禁 —— `build_parts()``tri_group.material_index` 拆 surface + 用 `Mesh::material_textures`mesh-local)取贴图。shaman/assassin 多材质面正确。逐材质 oracle 纸面对拍可选
3. **修 libgr2 `Curve::eval` degree-2 四元数 B 样条**(对拍 Granny oracle)—— 这条其实是 xrender-poc/libgr2 的活,两条 route 共享收益;修好后表情动作即可用 3. ~~修 libgr2 曲线~~ 误判 —— CPU 蒙皮转默认(见 §4)
4. 前台窗口 / 真机重测性能,给 Phase 2 的动画 A/B 一个可信基线 4. ✅ 前台性能重测(`test/godot-macos-stress.json`):30 角色 24fps;瓶颈是每帧 mesh 全量重建
5.`anim_probe` 全 race 扫(`test/shear-bones-survey.md`):warrior 7 根、**sura 16 根** shear 骨;assassin/shaman 干净。据此**已实现自写 GPU 蒙皮顶点着色器**(`SRC_SKIN`,逐骨完整 4×3 矩阵)—— `MTGODOT_GPUSKIN=1` 现在走它、不再走坏的 `Skeleton3D`。warrior `dance_1` GPU 路径与 CPU 一致(`test/compare-shots/*.gpu.png`)。
6.`.msa` 接线:`anim_path``.msa``MotionFileName` + `Accumulation``get_accumulation`+ `MotionEventData``get_events()` + `motion_event` 信号,带循环回绕)。`throw.msa` 事件 t≈0.83 触发。
7. 🟡 `.msm` 接线:`gr2_path``.msm` → 解析 + 解析 `BaseModelFileName`(含 eterpack 散包目录扫描)自动加载 base model + `get_hair_options()` 发型目录(75 项)。`warrior_w.msm` 实测。**发型 mesh 实际挂接仍 ⬜** —— 需 base 骨架的逐帧矩阵(与 anim player 共享),属 Phase 2 模型组合。
> Godot route 本身的桥接层已验证正确`wait` 渲染与 xrender bgfx demo 一致,骨骼矩阵 ≤2.6e-5CPU/GPU 蒙皮一致)。#3 是把已知的 upstream 曲线 bug 补上,不是 Godot route 的缺陷。 > Godot route 桥接层已验证正确:数值真值 = `oracle_diff`libgr2 vs 真 Granny 2.9.1223 用例 ≤6.5e-5),
> 两条蒙皮路径都逐顶点作用这批 `skin[]` 矩阵;CPU 与 GPU 渲染 `wait` / `dance_1` 与 xrender bgfx demo 抽帧一致。
> 原 #3「libgr2 曲线 bug」是误判,真因是 Godot 内置蒙皮(`set_bone_pose` / `Skeleton3D`)正交化丢 shear,已用自做蒙皮规避。
> (旧文里的「`get_bone_global_pose` vs `conv(world)` ≤2.6e-5」是已删的 `Skeleton3D` 校验路径的数字,不再适用。)
不建议现在做的:地形、`.mse` 特效、eterpack、UI/Python —— 均按计划留到正式移植。 不建议现在做的:地形、`.mse` 特效、eterpack、UI/Python —— 均按计划留到正式移植。
> 「离一个可用客户端还差什么」的完整分层清单见 [`BACKLOG.md`](./BACKLOG.md)A Phase-1 收尾 /
> B 材质保真 / C 动画运行时 / D 模型组合 / E 渲染功能 / F 平台 / G 资产管线 / H 测试工程 /
> I libgr2 覆盖面),含条目 ID、粗估规模、排期。
+248
View File
@@ -0,0 +1,248 @@
# 渲染一致性差距清单 —— 距 `ingame-shinsoo.png` 还差什么
> 更新:2026-08-29 · 配套 [`SHINSOO-WORLD-RENDERING.md`](./SHINSOO-WORLD-RENDERING.md)(实施规格)、
> [`BACKLOG.md`](./BACKLOG.md)。
>
> **本文回答一个问题**:现在 `test/golden/world-a1-{persp,r2}.png` 到「与游戏内截图目视等价」之间,
> 还有哪些工作。不是 SHINSOO 的重复 —— SHINSOO 是「怎么把世界渲染出来」,本文是「怎么把观感做到
> 参考图那样」。当前已落地 W0–W6 的可运行纵切,以及 W7/W8 的部分首版;必要 `.mse`、真实 UI、
> 完整流式性能路径和移动端验证仍未完成。本文同时列出**正确性缺口**与**保真化缺口**。
---
## 0. 「一致」的定义与现状
**目标**:在同一全局坐标 + 同一 yaw/pitch/FOV + 同一时间/天气下,Godot 输出与原 D3D 客户端截图,
经 ROI 直方图 + 边缘结构(SSIM)+ 人工签核判为「同一场景、同一观感」。不要求逐像素(渲染器不同)。
**现状**`world-a1-persp.png` / `world-a1-r2.png`):
- ✅ 地貌 / 道路网 / 建筑布局 / 水域形状 = **认得出是神兽国 A1**
- ✅ 第三人称角色(带发型)在地形上跑、HUD 布局在位、`.msenv` 驱动的天空/光/雾
- ❌ 观感**不等价**:树仍是 proxy(虽已用真实 bark/composite atlas)、无 `.mse` 特效、水面已用真 30 帧序列但深度系数未标定、HUD 已用真实九宫格贴图但布局未按受控帧对齐、光照/色调未对受控帧校准、机位随手放、角色材质 sphere-map 高光管线已端口但默认休眠(无装备层驱动 power)
**粗估**:把下面全部做到「目视等价」,单人 **≈ 36 个月**(不含 Phase 2 移动端 bring-up
需在地形/水面正确性修复和 `.spt` 转换可行性验证后重估)。`.spt` 当前缺少可分发的跨平台 reader;
`.mse` 是 ASCII 文本且原客户端带 parser,不属于不可读资产,但完整 EffectLib 运行时移植仍是大工程。
### 0.1 `ingame-shinsoo.png` 受控参考契约
本文的具体目标帧不是泛指「某张神兽国截图」。实施前必须创建
`test/maps/ingame-shinsoo.json`,把下列状态冻结;无法从截图可靠反推的值一律写成
`待采集`,不能凭观感猜一个值后继续调材质。
| 域 | 必须冻结的字段 |
|---|---|
| 输出 | 1920×1080、Godot 版本、renderer、MSAA/TAA、色彩空间、随机种子 |
| 地图 | map id、全局坐标、加载半径、可见区块 |
| 相机 | yaw、pitch、roll、与角色距离/高度、FOV、near/far |
| 角色 | race、sex、body/hair、武器/装备、动作名、动画时间/帧 |
| NPC | race、装备、全局坐标、动作帧、名字/标签状态 |
| 环境 | `.msenv`、时间/天气、太阳方向、雾、曝光、后处理参数 |
| UI | locale=`es`、背包打开、圆形小地图、聊天/状态栏/快捷栏以及顶部三行消息内容 |
**门禁**:上表未落盘前,只允许修格式/管线正确性问题,不接受「为匹配这张图」进行的相机、光照、
色调或材质调参。参考图本身应进入仓库为 `test/reference/ingame-shinsoo.png`,避免依赖个人目录路径。
### 0.2 单帧范围与完成定义
对这张目标图,阻塞交付的可见项按顺序是:固定机位 → 地形全部 splat/UV → 建筑与材质 →
非占位树 → 玩家/NPC/装备/阴影 → 光照与色调 → 真实 HUD。水体、完整 `.mse`、移动端流式和远离
机位的地图内容,只有在目标帧或其验收 ROI 内可见时才阻塞该单帧;被排除项必须在 preset 中记录
原因和对应 ROI,不能把「当前看不到」写成「已经支持」。
单帧完成同时要求:
- 世界层能辨认为同一位置、同一构图,主要建筑、道路、树冠和角色轮廓对齐;
- UI 层在 1920×1080 下使用真实贴图/字体,窗口边界、锚点、层级与参考图一致;
- 通过 §9 的世界 ROI、UI 几何门禁与人工签核;不同渲染器导致的逐像素差异不作为失败条件。
### 0.3 精确资产清单
另建 `test/maps/ingame-shinsoo-assets.json`,逐个登记目标帧里**实际可见**的资源,而不是只写资源类别:
| 可见对象 | 清单必须记录 |
|---|---|
| 地形 | map/区块、`height.raw``tile.raw`、TextureSet 图层及 shadow/minimap |
| 建筑/道具 | property CRC、buildingfile、实例变换、GR2、每个材质贴图 |
| 树 | property CRC、treefile、实例变换、最终使用的 mesh/LOD/impostor 与树皮/叶片贴图 |
| 玩家/NPC | MSM/MSA/GR2、body/hair、武器装备、动作和实例位置 |
| UI | 每个窗口的脚本/贴图/locale 图、字体、图标和九宫格来源 |
| 环境 | `.msenv`、天空/云/flare 贴图以及所有颜色、雾、曝光输入 |
每条资源保存原虚拟路径、`AssetResolver` 最终命中的磁盘路径、pack 优先级和内容 hash。这样 assets
目录内容变化后,golden 偏差能够定位到具体资源,而不是重新肉眼猜测。
---
## 1. 特殊资产与专项管线
| # | 项 | 为什么卡 | 实施路径 | 规模 |
|---|---|---|---|---|
| 1.1 | **`.spt`SpeedTree 树)** | 几何解析在闭源 `CSpeedTreeRT::LoadTree()`;仓库静态库实测为带符号的 **COFF x86-64**,不能链进 macOS/移动端且分发需确认授权。`formats/spt` 已能嗅探 bark/composite atlas,真实资产共 **118** 个 | (a) 先用随库 `SpeedTreeRT.h` + `.lib` 验证经授权的 Windows x64 离线 exporter,调用 `LoadTree/Compute/GetGeometry``.glb`;(b) 当前运行时 proxy 使用真实 bark/composite DDS(c) exporter 不可用时继续用授权清晰的替代树 | L(exporter spike 约 1 周;成功后转换与校形 2–3 周) |
| 1.2 | **`.mse`(特效 / 粒子)** 🟡 首版打通(见 `CLIENT-ROADMAP.md` P5| 资产是 ASCII Group/List 文本;`EffectLib/EffectData.cpp``ParticleSystemData.cpp``EffectMesh.cpp``SimpleLightData.cpp` 已包含 parser 和运行时真值 | ✅ `project/fx/``mse.gd` 文本树解析(真文件 6 particle)· `effect_player.gd` Particle→`GPUParticles3D`(发射形状/曲线/颜色渐变/billboard/加法混合)· `effect_registry.gd` 名字→`.mse`+缓存 · `wire.h` `GC_SPECIAL/SPECIFIC_EFFECT``M2Client.effect_cue`。⬜ `.dds` 粒子纹理、`.mde` mesh 解码、SimpleLight(本资产集未用)、绑骨骼挂点、时间轴位置动画 | L–XL(parser 端口 + 运行时/材质/挂点,独立子项目) |
| 1.3 | **lens flare** | `.msenv``LensFlare` 块(已解析),但需屏幕空间实现 | 太阳屏幕投影 + flare billboard;遮挡先用相机到太阳方向的物理射线,或在后处理里读深度。若使用低层 occlusion query,必须基于锁定的 Godot 4.7 API 实证,不使用不存在的 `get_1d_probe` | SM |
### 1.4 树木方案门禁
A1 已确认 **368 棵 / 14 树种**,全资产树库共 118 个 `.spt`;现有 R2 proxy 已按 treefile 分组为
`MultiMeshInstance3D`,从 `.spt` 嗅探真实树皮/composite atlas,并生成确定性枝干与多组交叉叶簇。
它仍没有真实 SpeedTree 几何、leaf table、LOD/impostor。批量开发前先从最终机位按屏幕投影面积选 3 个代表树种,
分别验证以下来源:
1. 具备授权且能实际读取这些旧 `.spt` 的 SDK/离线 converter
2. 保留原 treefile → 替代 mesh 映射的人工/半自动重建;
3. 改良程序化树,仅作为 converter 和替代 mesh 都不可行时的降级路径。
每条路径都必须产出同一份 `TreeDescriptor`(树干/叶片材质、LOD0/1、远景 impostor、包围盒、风参数),
并以树冠轮廓、树干比例、叶片 alpha、环境着色、阴影、LOD 跳变、批次数和授权可分发性签核。
未通过单树 spike 前不批量处理 118 个 `.spt`,运行时也不得直接依赖 Windows x64 专有 `.lib`
**实例语义修正(已落地)**:旧 `Metin2World` 会给每棵占位树额外生成随机 yaw 和 scale jitter;原客户端
`CArea::TObjectInstance::SetTree``CSpeedTreeForest::CreateInstance` 在这条地图放置路径上只传
position、property CRC 和 treefile,实例本身只调用 `SetPosition``LoadTree` 也没有从这里收到
TreeSize/TreeVariance。当前实现已移除这些随机变换;若替代树为避免重复确实需要
随机旋转,应作为明确的 fallback/增强开关,而不能污染基线。
---
## 2. 角色 / 模型保真
| # | 项 | 现状 | 目标 | 工作 | 规模 | BACKLOG |
|---|---|---|---|---|---|---|
| 2.1 | 武器 mesh | 🟡 **首版已做 + 2026-08-31 复核**`Metin2Model.weapon_gr2` / `weapon_bone`(默认 `equip_right_hand`warrior bone[10])。加载武器 gr2 → `build_mesh`rigid)→ 子 `MeshInstance3D`;每帧 `update_weapon_pose(world_pose[bone])`。**隔离探针实测**`warrior_novice`/`warrior_cheongrin` × 8 个武器文件,weapon MI 全局原点 == `equip_right_hand` 骨骼全局原点,Δ = 0.000.06 mworld_demo 目视剑握在手里。之前记忆里「武器偏 ~10 m」是 cpu_skin `>=` / MapCoord 修复前的旧状态,已失效。新增 **grip pre-transform**`weapon_pre = mul4x3(weaponInvWorld[0], weaponLocal[0])`,对齐客户端 `weaponComposite[0]` = `ModelInstanceUpdate.cpp:171`),`update_weapon_pose``mul4x3(weapon_pre, world_pose[hand])`;标准武器多校准 ~4–6 cm 握点。个别非标准文件(`02010`/`03010`/`03150`)骨骼带米级偏移 → 30 cm 夹断退回原始手部位姿(不回退)。`mul4x3` 提到 `gr2_bridge.h` 与 anim 共用 | 右手单持跟手 1:1;`03150` 类网格在文件里就离骨骼原点 ~3 m(资产缺陷,非变换问题)—— 需 per-weapon 偏移数据或修 .gr2。⬜ 左手/双持/盾、`_lod` 变体 | M | C5 / D2 |
| 2.2 | GPU 蒙皮路径的发型 | ✅ **已做**`_build_gpu_mesh()` —— `enable_gpu_skin` 带发型时,把发型 mesh 以 bind pose 并入 GPU mesh,每个 `ARRAY_BONES` 值经 `hair_bone_remap`(发型骨 idx → base 骨 idx)重映射。**无需改 shader / 第二骨索引区**warrior 74/74 全匹配)—— `SRC_SKIN` + 共享 `bones_tex` 直接形变。`_apply_materials` 给追加的发型 surface 上「skinned + alpha-test + hair 贴图」材质。实测 5 base + 1 hair surface,红发(TargetSkin)贴头、镂空干净、跟动画 | M(完成)| A1 |
| 2.3 | 换装 / 时装 / 翅膀 | 无 | 多 `MeshInstance3D` 绑同一骨架 + 部位遮挡 / 隐藏规则 | `.msm` + 物品系统的上层 | L | D1 / D4 |
| 2.4 | 贴图换色 `SourceSkin→TargetSkin` | ✅ **首版已做**`Metin2Model.hair_skin` —— 设了就用该 dds 当发型 albedo(代替 gr2 sibling 的 SourceSkin),对齐客户端 `SetMaterialImagePointer(part, SourceSkin, load(TargetSkin))``ActorInstanceData.cpp:152/225`)。`get_hair_options()` 现返回解析成绝对路径的 `source_skin` / `target_skin``formats.msm_hair` CTest 钉了 HairData 解析(同 gr2+SourceSkin、不同 TargetSkin)。demo 用红色变体 | ⬜ 非发型部位(护甲/时装)的 skin 表;`warrior_cheongrin` 头盔挡住发型,换色需换模型目视 | S(首版完成) | D3 |
| 2.5 | 固定功能材质状态覆盖 | 单 stage `MODULATE(TEX,DIFFUSE)` 近似;**sphere-map 分支已按源码端口**(见 §2.7)。**blend / two-sided 改读 `gr2::MaterialInfo`**`metin2_model::decide_blend()` 优先 `alpha_blend``dump_materials` 端口 `Material.cpp:233` = Name `Blend*` + MapCount>1),`decide_two_sided()``two_sided`+ hair/cloak 名兜底),`m2_material``cull_disabled` shader 变体。仅 hair 镂空 / effect 加法这两类 GR2 没编码的仍按表面名 | 复现最终机位实际触发的 alpha-test ref、多贴图组合 | 逐分支盘点 `EterGrnLib/Material.cpp`GR2 不承载任意 D3D stage 状态 | M | B2/B4 |
| 2.6 | 顶点色 | ✅ **排查完毕:不适用**。全资产 `.gr2`PC / monster2 / effect**零** `DiffuseColor`/`Color0` 顶点成员(grep 整个 assets 目录 = 0)。Metin2 的「逐顶点颜色」实际是:① 地形雾 —— `MapOutdoorRenderSTP` 运行时动态 VB 里 `dwDiffuse`/`dwSpecular` 存雾色/雾系数(非资产,已由 Godot `Environment` 雾覆盖);② `.mse` 特效 `colorfactor` / 粒子 `TimeEventColor`(属 §1.2 `.mse` 端口)。libgr2 无需改,`build_mesh` 无需补 `ARRAY_COLOR` | — | B7 |
| 2.7 | 镜面 / 球面反射(护甲金属光) | 🟡 **管线已端口**`SRC_MIX` / `SRC_SKIN``spec_map`/`spec_power`/`spec_enable`。按 `EterGrnLib/Material.cpp:305 __ApplySpecularRenderState` 复现:仅不透明面(客户端 alpha-blend 时回退纯 diffuse)、相机空间 `reflect(VERTEX,NORMAL).xy` 查 sphere map、`MODULATEALPHA_ADDCOLOR` = `EMISSION += sphere.rgb * tex.a * power``tex.a` = 护甲高光遮罩,`power` = `D3DRS_TEXTUREFACTOR.a`)。sphere map = 全局 `ETC/ymir work/special/spheremap.jpg`(客户端 index 0),`Metin2Model::_load_sphere_map()` 沿模型目录上溯解析。`Metin2Model.specular_power` 属性。**默认 0 = 完全休眠**,与之前逐字节一致 | 端口 `__ApplySpecularRenderState`(✅);接装备层用 `item_proto bSpecular/100` 逐部位驱动(⬜,无物品系统);受控帧标定 power(⬜,§0 门禁) | M | B9 |
| 2.8 | 角色专属光(`DirectionalLight.Character` | 只用 `Background` 光 | 角色颜色与原客户端 Character light 接近 | 给 `SRC_MIX`/`SRC_SKIN` 加 character-light uniform,或用 light `cull_mask` 分层 | SM | E2 |
| 2.9 | 动画混合 / crossfade | ✅ **首版已做**`Metin2AnimPlayer.blend_time`(默认 0.15s)。切 `anim_path` 时保留旧 clip 冻结在当前时间,每帧对旧/新 `sample_pose``world_pose` 逐骨 slerp(rot)+lerp(pos/scale)smoothstep 权重),再 `skin = invWorld · world_blended` 重建;武器挂点跟混合后的手。shear 骨在 ≤blend_time 过渡内退化为无 shear(文档许可)。目视 wait→run 连续无 T-pose 闪 | ⬜ 受控帧核对过渡时长 | M(首版完成) | C2 |
| 2.10 | LOD | ✅ **首版已做**`Metin2Model` reload 时自动加载 `<base>_lod_01/02/03.gr2`(同 75 骨骼、抽面 mesh),`_process` 按相机距离切换渲染 mesh`lod_distances` 属性,默认 `[18,42,90]`m)。切换时重建 `cpu_mesh` + 重解材质 + 重蒙皮;骨架共享故蒙皮不受影响。烟测:`d=12→L1 d=21→L2 d=30→L3``wait.msa` 播放中不崩),4 级渲染均干净。⬜ 淡入淡出过渡、GPU 蒙皮路径的 LOD | M(首版完成)| C9 |
| 2.11 | `ypr_basis` 符号终核 | ✅ **发现并修复真实 bug**:物体旋转 `yaw#pitch#roll` 之前**未做** Z-up→Y-up 轴共轭就塞进 Godot Basis,导致 66% 的 A1 物体(roll≠0 = 朝向)绕 Godot Z 翻滚而非绕 Y 转向。新增 `m2_coord::object_basis_godot()` = `ypr_basis()` 共轭 `rotate(-90°,X)``metin2_world` 放置改用它。已核对 `ypr_basis` 本身忠实移植客户端 `D3DXMatrixRotationYawPitchRoll``Area.cpp:520/619` + `GrpObjectInstance.cpp:165`;单值旋转=roll=朝向,`Area.cpp:820`)。`formats.map_formats` 加断言(identity / roll=90→绕 Godot+Y / 正交 det=+1)。目视:A1 建筑竖直、门楼与桥沿路对齐 | ⬜ 对原客户端截图的逐建筑朝向复核(§0 受控帧) | S(首版完成) | — |
---
## 3. 地形 / splat 保真
| # | 项 | 现状 | 目标 | 工作 | 规模 |
|---|---|---|---|---|---|
| 3.1 | 活动图层上限 | ✅ **首版已修**shader 支持 **16 层**4 张 RGBA8 权重贴图),`min(8)``min(16)`A1 单区块最多 12 层(`000004`/`003004`)不再静默丢 | 每区块全部活动图层按原顺序混合 | ⬜ 按 patch 分批省带宽;真实 A1 层数断言 | M(P1,首版完成) |
| 3.2 | 每图层平铺密度 | ✅ **首版已修**`layer_uv = (8·UScale, -8·VScale, UOffset, -VOffset)`,来自 `TextureSet.cpp:185` + `TexCoordBase = 1/(PATCH_XSIZE·CELLSCALE) = 1/3200`。⬜ 对 `minimap.dds` / 受控参考帧的视觉验收 | 精确移植原客户端 UV 变换 | — | S–M(P1,首版完成) |
| 3.3 | `shadowmap.dds` 调制 | 🟡 **首版已分层**:树 `MultiMeshInstance3D` + `shadowflag≠1` 的建筑/地牢块 → `SHADOW_CASTING_SETTING_OFF``shadowflag=1` 建筑 + 玩家/NPC → 实时投影。terrain shader 仍 `col *= shadowmap.rgb`。**+ 角色接触阴影**`project/ui/char_shadow.gd``CharShadow.attach`)—— `player_view`/`mob_view` 建完模型后强制所有 `MeshInstance3D` `cast_shadow=ON`(防被 world 的 OFF 波及)+ 脚下一张径向渐变 `Decal` 投影到地形/物体(对齐 `GameLib/MapOutdoorCharacterShadow.cpp` 的结果,不复刻 RT)。太阳接近正午实时投影很短时也有接触暗影。`eterngrn_polish_test.gd` 覆盖 | ⬜ baked/realtime 精确不叠加的 mask、Decal 参数受控帧标定 | M(首版完成) |
| 3.4 | 近景清晰度 | ✅ **首版已修**:删掉程序化 `macro_detail`Texture2DArray 尺寸取「用到的图层里最大源边长(≤1024)」,只放大不硬降 512² 地表贴图 | 严格 parity 基线不叠加现代化增强;确认需要后再加可开关 detail map | — | SM(首版完成) |
| 3.5 | 地形 LOD / patch 裁剪 | 🟡 **首版已做**`Metin2World.terrain_patches`(默认 4)—— 每区块地形拆成 N×N 个 `MeshInstance3D` patch16 个,各 32×32 quad),共享 splat 材质。逐 patch **视锥剔除**Godot 自动,主要增益)+ `visibility_range_end`(默认 3500m 硬剔)远景整片剔除。顶点/法线/UV 从整块 `TerrainMesh` 拷贝 → **无裂缝无接缝**(顶视 + 斜视验证)。`terrain_patches=1` = 旧单 mesh | ⬜ 逐 patch 几何 LOD(抽稀索引集 + skirt 防裂);碰撞体也按 patch | M(首版完成)|
| 3.6 | 草(instanced grass) | 无;当前参考截图不能明确证明存在独立草片几何 | 只有原客户端源码、属性或受控近景帧确认后,才在对应区域撒 `MultiMesh` 草片 + 风摆 | 先取证,避免把现代化增强混入 parity 基线 | 可选 M |
| 3.7 | 相邻区块共享边断言 | ✅ 已加 `formats.map_formats` seam 断言:`000000` col-128 高度 == `001000` col-0(边界样本)+ 生成顶点位置一致,实测无接缝 | — | — | S(完成) |
---
## 4. 水面
| # | 项 | 现状 | 目标 | 工作 | 规模 |
|---|---|---|---|---|---|
| 4.1 | 原客户端水纹序列 | ✅ **首版已做**`special/water/01..30.dds``Texture2DArray`shader `frame = (TIME·1000/70)%30``MapOutdoorWater.cpp:43` 实测 70ms/帧),UV 平铺 = 1/8m`m_fWaterTexCoordBase = 1/(CELLSCALE·4)`)。⬜ wrap/filter 细调、原客户端叠加 blend 状态 | — | S–MP1,首版完成) |
| 4.2 | 水深透明与高度动画 | ✅ **首版已做**:逐顶点 `COLOR.a``waterHeight - terrain_height_at()` 算(60cm 深→近不透明),vertex shader 轻微高度浮动(近似 `MapOutdoorWater` 0..-15cm 的 0.6Hz 摆动)。⬜ 深度衰减系数按受控参考帧标定(现 A1 浅水偏透明) | — | M(P1,首版完成) |
| 4.3 | 反射 | 无真实反射 | 若受控参考帧证明必要,使用镜像相机 + `SubViewport` 做平面反射,或以 SSR 近似 | `ReflectionProbe` 是 cubemap,不作为平面反射实现 | 可选 M |
| 4.4 | 折射 | 无 | Godot 4 sampler `hint_screen_texture` + `SCREEN_UV` 扰动,结合 depth sampler 衰减 | 属于增强项;原客户端水面 parity 完成后再做 | 可选 SM |
| 4.5 | 岸线泡沫 / 法线贴图 | 无 | 仅在参考帧明确需要时增加;不宣称原客户端使用 normal map | 与严格 parity 材质做开关和 golden 对照 | 可选 S |
| 4.6 | 网格合并 | 同水层/区块已经是一个 mesh surface,内部按行生成 span quad | 行列矩形合并只减少顶点/索引;要减少 draw call 需跨区块/水层合批或统一 surface | 分别记录 vertex/index 与 draw-call 指标,避免把几何优化写成 draw-call 优化 | S |
---
## 5. 环境 / 光照 / 色调
| # | 项 | 现状 | 目标 | 规模 |
|---|---|---|---|---|
| 5.1 | 云层 | `.msenv` 有 cloud texture/scale/height/speed(已解析),未渲染 | 天空 shader 加滚动云层 + `List CloudColor` 着色 | M |
| 5.2 | 光照对受控参考帧校准 | `.msenv` → Godot 近似,肉眼调 | 在原 D3D 客户端到已知机位截图 → Godot 复现 → ROI 亮度/饱和/主色迭代到匹配(SHINSOO §11.4) | M(含反复采集) |
| 5.3 | 阴影质量 | 4-splitcascade 500mbias 调过 | 逐场景标定 cascade split / normal bias / peter-panning;角色脚下清晰接触阴影 | S–M |
| 5.4 | 昼夜 / 天气 | 无 | 若参考图之外还要,`.msenv` 多套 + 插值 | M(可选) |
| 5.5 | 色调校准 / LUT | `adjustment` 饱和/对比 | 先用同机位、同曝光的多组 ROI/校准帧拟合曝光、曲线、饱和度;必要时再固化为 3D LUT。单张场景截图不足以唯一反推出 LUT | S–M |
---
## 6. HUD / UI
目标截图的 UI 不是「有相似窗口即可」。建立 `test/maps/ingame-shinsoo-ui.json`,为圆形小地图、右侧已打开
背包、底部聊天/HP/MP/快捷栏以及顶部 objective/action/绿色提示逐项保存:1920×1080 像素 rect、anchor、
pivot、z-index、九宫格边距、贴图/字体路径、字号、描边、颜色、文本和 visible 状态。布局比对直接读取
该文件,禁止在测试里散落第二套魔法数字。
| # | 项 | 现状 | 目标 | 规模 |
|---|---|---|---|---|
| 6.1 | UI 美术 | 🟡 **机制已做**`hud.gd` 用真实 `ETC/ymir work/ui/pattern/` 贴图 —— `board`/`thinboard` 九宫格(4 角 + 4 边 + `*_base` 合成 atlas → `NinePatchRect`margin=32/16),HP/MP 用 `gauge_slot_{left,center,right}` 框 + `gauge_red`/`gauge_blue` 填充,快捷栏 8 格 `thinboard`,小地图套 `board` 框。贴图按资产根 + 各 pack `ymir work/ui/pattern/` 解析(`.tga``Image.load`)。⬜ 精确布局(rect/anchor/字体/描边)待 §0 `ingame-shinsoo-ui.json``locale/<lang>/ui/` 本地化图;`windows.dds` atlas 子图 | M(机制完成) |
| 6.2 | 小地图 | `minimap.dds` 拼图 + 玩家点 | 先保持地图 north-up、按玩家 heading 旋转箭头;若原客户端配置/受控帧证明整图旋转,再实现地图旋转。补缩放、NPC/怪/出口图标和交互 | M |
| 6.3 | 状态栏 / 快捷栏 / 背包 | 静态占位 | 真实布局 + 数值绑定 + 拖拽 + tooltip(不接服务端物品逻辑) | M–L |
| 6.4 | 头顶文字 / 血条 | 无 | `Label3D` 名字 + 屏幕空间血条跟随 | S–M |
| 6.5 | 聊天框 / 系统消息 | 无 | `RichTextLabel` + 频道 tab(参考图里通常有) | S |
| 6.6 | 1920×1080 布局 | 1152 测试分辨率 | 目标分辨率像素对齐 + 缩放策略 | S |
---
## 7. 后处理
| # | 项 | 现状 | 目标 | 规模 |
|---|---|---|---|---|
| 7.1 | Bloom / glow | 🟡 已收敛(intensity 0.35→0.12hdr 阈值 1.1→1.5,bloom→0;曾让角色发糊)。参考帧未落盘前保守 | 按参考图高光溢出量标定 | S |
| 7.2 | SSAO | 🟡 已收敛(intensity 1.4→0.7)。参考帧未落盘前保守 | 标定强度 / 半径 | S |
| 7.3 | 抗锯齿 | MSAA 4× | + TAA 或 FXAA(移动端权衡) | S |
| 7.4 | 景深 | 无 | 参考图若有远景虚化则加 | S(可选) |
| 7.5 | 暗角 / 色差 | 无 | 参考图若有则轻加,禁过度 | S |
---
## 8. 相机 / 构图
| # | 项 | 现状 | 目标 | 规模 |
|---|---|---|---|---|
| 8.1 | 机位匹配 | 🟡 渲染分辨率已提到 1920×1080(`project.godot`);机位仍是脚本里随手放(fov 60) | 精确复现参考图高度/距离/pitch/FOV,存 `test/maps/` 确定性 preset | S |
| 8.2 | 相机遮挡淡出 | ✅ **首版已做**`Metin2World` 给每个静态物体加层 2 盒碰撞(mesh AABB → Y-up 米,非缩放 shape`occ_mesh` meta 指回 MeshInstance3DA1 601 个)。`game_camera.gd::_fade_occluders` 每帧 cam→player 射线(ray exclude 逐个穿,最多 4 层)→ 命中建筑 `GeometryInstance3D.transparency = 0.72`,不挡时恢复 0。⬜ 树(MultiMesh 无逐实例透明)、渐变淡入淡出 | M(首版完成) |
| 8.3 | 相机碰撞 | ✅ **首版已做**`game_camera.gd::_desired_pos` head→want 射线打层 2,命中建筑就把相机拉到撞点前 0.3m(叠加已有地形采样防穿)。静态物体碰撞体作为 8.2 的副产品一并落地(`player_controller` 仍用 `attr.atr` 网格,可后续接物理)。⬜ `SpringArm3D` 平滑、`.mdatr` 精确碰撞体 | M(首版完成) |
---
## 9. 验证方法论(做「一致」的前提)
| # | 项 | 说明 | 规模 |
|---|---|---|---|
| 9.1 | 受控参考帧采集 | 在原 D3D 客户端脚本化到同一「已知全局坐标 + yaw/pitch + FOV」连续采两帧:关 HUD 的世界帧与开 HUD 的合成帧;参数存进 `test/maps/`。原客户端不能脚本化则「同地标 + 人工对齐 + 只比 ROI」 | M |
| 9.2 | ROI 直方图 + SSIM 门禁 | `test/compare.py` 扩:世界帧按天空 / 道路 / 草地 / 建筑 / 树 / 角色分区,比均亮 / 饱和 / 主色 / 边缘结构;合成帧另做 UI rect 与文本基线比对 | M |
| 9.3 | golden 锁定项 | Godot 版本 / renderer / 窗口 / 相机 / 时间 / 动画帧 / 随机种子 / 曝光 / 天气 / 资源优先级 全锁 | S |
| 9.4 | `world-a1-r1/r2.png` 进 CI | `formats/tests` 已进 CTestgolden 视觉门禁待接 | S |
| 9.5 | 目标帧验收配置 | 在 `ingame-shinsoo.json` 为天空/草地/道路/建筑/树/角色分别保存 ROI、metric、thresholdUI 窗口 rect/锚点初始容差为 1 px | S |
| 9.6 | 阈值冻结规则 | 同一机器连续渲染 5 次测噪声底;世界 ROI 阈值必须高于噪声并经一次人工签核后冻结。不得在回归失败时临时放宽阈值 | S |
---
## 10. 平台(SHINSOO 的前置,不是观感项但影响「能不能发」)
- Android(一加 13 / Vulkan+ iOSiPhone 16 / Metal,静态链接)bring-up**一次都没跑过**。见 `BACKLOG` F1F7。
- 所有新 shader`SRC_TERRAIN` / `SRC_WATER` / `SRC_LEAF` / `SRC_SKIN`)在 Mobile renderer 下未验。
- 移动端性能预算(流式 + `MTGODOT_GPUSKIN` 人群)未测真机。
---
## 11. 建议推进顺序
**第 1 梯队(性价比最高,1–1.5 月)**
1. 0.10.3 目标帧/资产/UI 契约 + 8.1 机位 preset + 9.1/9.5 受控参考与验收配置 —— 没有基准就没法谈「一致」
2. 3.1 全活动 splat 图层 + 3.2 原始 UV 公式 —— 先修复会丢内容/尺度错误的地表正确性问题
3. 1.4 最终机位可见树种清单 + 单树 spike;先用替代 mesh 达到轮廓,再并行验证授权 converter
4. 5.2 光照校准 + 5.5 色调拟合 —— 全画面观感一次性拉近
5. ✅ 2.1 武器(右手单持)+ ✅ 2.11 ypr 终核(修了漏轴转换 bug+ ✅ 2.9 crossfade + ✅ 2.4 换色 —— 角色补齐(首版)
6. 🟡 6.1 HUD 真美术 —— 九宫格机制已上,布局待 §0;✅ 3.3 阴影分层首版
**第 2 梯队(保真化,1.5–2 月)**
7. 1.1 `.spt` reader/converter 通过授权与技术验证后批量离线转 Mesh,并接入统一 `TreeDescriptor`
8. 4.1–4.2 原客户端 30 帧水纹 + 深度 alpha;4.3–4.5 只按受控参考帧选择性增强
9. 5.1 云层 + 1.3 lens flare
10. ✅ 2.7 sphere-map 高光(管线已端口,默认休眠);⬜ 2.5 其余固定功能材质分支 / 2.8 角色专属光
11. 3.6 instanced grass(仅在取证确认后)
**第 3 梯队(大工程 / 可并行 / 部分可选)**
12. 1.2 `.mse` parser/EffectLib 语义到 Godot 的端口 —— 独立子项目,L–XL
13. 2.3 换装系统、6.26.5 完整 HUD
14. 10. 移动端 bring-up
**贯穿**:每个梯队做完过一次 9.2 的 ROI 门禁 + 人工签核,用数据说话,别靠感觉。
+201
View File
@@ -0,0 +1,201 @@
# Platform builds (macOS / iOS / Android)
Phase 1 shipped the GDExtension for macOS only. As of 2026-08-30 the build system
is opened up for the two Phase 2 devices (BACKLOG F1/F2): **iPhone 16** (iOS,
Metal) and **OnePlus 13** (Android arm64, Vulkan). The native deps that used to
come from Homebrew are now vendored and cross-compile from the same tree — see
`docs/THIRD-PARTY.md`.
## What each script does
| Script | Target | Output |
|---------------------|---------------------------|---------------------------------------------------------|
| `./build.sh` | macOS (host) | `project/bin/libmtgodot.macos.template_{debug,release}.dylib` |
| `./build-ios.sh` | iOS arm64 device (`OS64`) | `project/bin/libmtgodot.ios.template_{debug,release}.a` + staged deps in `project/bin/ios/` |
| `./build-android.sh`| Android `arm64-v8a` | `project/bin/libmtgodot.android.template_{debug,release}.arm64.so` |
| `./export-android.sh [Debug\|Release] [--install]` | Android APK (wraps build-android + Godot export) | `build/export/mtgodot-poc[-release].apk` |
| `./gen-debug-keystore.sh` | Android debug keystore (once) | `~/Library/Application Support/Godot/keystores/debug.keystore` |
| `./pack-assets.sh` | Metin2 assets → mountable zip | `build/export/assets.zip` (`zip -0`, ~2.2 GB) |
All three run `git submodule update --init --recursive` if needed. The host tools
(`packtool`, `net_probe`) and CTest suite build only on a host build
(`CMAKE_SYSTEM_NAME == CMAKE_HOST_SYSTEM_NAME`), never when cross-compiling.
`project/bin/mtgodot.gdextension` has the `[libraries]` entries for all three
platforms and an `[dependencies]` block for iOS (see below).
## Status
### macOS — ✅ done
Unchanged. `./build.sh` → dylib, `ctest` 9/9.
### iOS — 🟡 compiles; export/sign/device pending (F1)
**Verified here (Xcode 26.4.1 + iPhoneOS 26.4 SDK):**
- `./build-ios.sh Debug` cross-compiles the whole surface to **arm64, minos 15.0**:
libsodium, libzstd, miniLZO, libgr2, xr_formats, mtnet, godot-cpp, and the
extension itself → `libmtgodot.ios.template_debug.a` (valid `ar` archive).
- iOS forces the extension to **STATIC** (`MT_LIB_KIND` in `extension/CMakeLists.txt`)
because the platform can't `dlopen`. Godot links the archive + its deps into the
app at export time, which is why `.gdextension` carries an `[dependencies]`
`ios.debug` / `ios.release` map listing every archive `build-ios.sh` stages
into `project/bin/ios/`.
**Not done (needs the device + Godot iOS export templates, ~23 days):**
- Install matching Godot 4.7 iOS export templates.
- Godot editor → Export → iOS preset; add `project/bin/ios/*.a` as extra link
libraries (or fold into an `.xcframework`).
- Free personal provisioning profile + signing for on-device install.
- First run on the iPhone 16; capture a frame for F3.
- Release build (`./build-ios.sh Release`) and size/strip pass.
### Android — ✅ APK builds; on-device run pending USB debugging (F2/F3)
**Done (2026-08-31):**
- `extension/CMakeLists.txt` handles `CMAKE_SYSTEM_NAME == Android` (SHARED `.so`,
`arm64` output-name suffix). `./build-android.sh` drives it through the NDK's own
`build/cmake/android.toolchain.cmake` (`ANDROID_ABI=arm64-v8a`, `android-24`).
- Toolchain on this machine: `sdkmanager "ndk;27.2.12479018" "platforms;android-35"
"build-tools;35.0.0"`; `brew install openjdk@21`; Godot 4.7.1 export templates
installed; `./gen-debug-keystore.sh`.
- `editor_settings-4.7.tres`: `export/android/android_sdk_path` = `~/Library/Android/sdk`,
`java_sdk_path` = `/opt/homebrew/opt/openjdk@21/.../Home`, `debug_keystore` = the
generated one (pass `android`).
- `project/export_presets.cfg` `[preset.1]` "Android": `arm64-v8a` only,
`gradle_build/use_gradle_build=false` (prebuilt template APK — no Gradle/JDK17 build),
`permissions/internet=true`, pkg `org.internal.mtgodotpoc`, minSdk 24.
- `./export-android.sh Debug` → `build/export/mtgodot-poc.apk` (~73 MB), signed with the
debug keystore via `build-tools/28.0.3/apksigner`. Contains
`lib/arm64-v8a/{libgodot_android.so, libmtgodot.android.template_debug.arm64.so, libc++_shared.so}`.
- Renderer stays `forward_plus` (OnePlus 13 = flagship Vulkan). No GLES3 / Compatibility
fallback — all target devices are modern.
**Asset IO portability (A1, 2026-08-31):** the extension used to read every asset with raw
`std::fopen` / `std::ifstream`, which cannot read a Godot PCK — so a bundled-assets APK
would launch to nothing. Fixed by routing all *running-extension* reads through
`godot::FileAccess` (works for `res://` PCK, `user://`, and absolute OS paths):
- `extension/src/asset_io.{h,cpp}` — `read_file()` / `dds_from_file()` / `gr2_from_file()`.
All `gr2::File::load_path` and `load_dds_path` call sites (model / anim / weapon / hair /
static objects / trees / terrain splat / water / shadowmap) now go through it.
- `formats/` funnels every read through `fmt::read_file`; added `fmt::set_file_reader()` and
`register_types.cpp` installs a `FileAccess`-backed reader → `height.raw` / `tile.raw` /
`water.wtr` / `.msenv` / `areadata.txt` / `map_setting` / `property` / `.spt` /
`texture_set` / `.msm` / `.msa` (textscript) all portable.
- `mtproto`: added `load_proto_bytes()`; `Metin2Proto` reads via `asset_io`.
- The standalone-lib `*_path()` functions are untouched, so the four non-Godot CTests
(`proto.item_mob`, `pack.roundtrip`, `formats.map_formats`, `libgr2.loader_errors`) stay green.
- **Phase 2b done (2026-08-31):** `fmt::AssetResolver` can no longer `std::filesystem`-scan a
PCK, so it's now a **baked index**: `AssetResolver::save_index()` / `load_index()` (line format
`MTIDX1` + `Y|R\t<key>\t<rel>`, paths relative to assets_root), and
`build_or_load(root)` → loads `<root>/asset_index.txt` via `fmt::read_file` if present, else
scans (desktop dev). `resolve()` now returns `assets_root + "/" + rel` so it's portable
(`res://assets/...` on device). Generate the index with
`godot --headless --path project --script bake_asset_index.gd` (→ `assets/asset_index.txt`,
~8 MB, 54k files) — `export-android.sh` does this automatically. `metin2_world.cpp` calls
`build_or_load`; `Metin2World.bake_asset_index(out)` is the bound builder method.
- **`eterpack` / `asset_source`** — unused while the dev asset tree is fully loose (no `.epk`).
### Bundling the assets (mobile) — the mount-a-zip approach
The APK/IPA is **code-only** (73 MB): `assets/` (2.1 GB) + `bgm/` (80 MB) are gitignored and
sit beside `project/`, not under `res://`. Getting them into `res://` via the exporter fails:
`.gdignore` blocks `include_filter` too, and without `.gdignore` Godot imports the ~10 k
`.dds`/`.tga`/`.jpg`/`.wav` → converts them to `.ctex`/etc → `FileAccess("res://.../x.dds")`
no longer yields raw DXT bytes → the C++ decoder breaks. (It also litters the asset tree with
`.import`/`.uid` sidecars and bloats `.godot/` to ~190 MB.)
Instead: **ship a plain `zip -0` and mount it at runtime.** All C++ asset IO now goes through
`godot::FileAccess` (see A1 above), so once the zip is mounted at `res://` everything reads
from it transparently.
- `./pack-assets.sh` → `build/export/assets.zip` (bakes `asset_index.txt` first; `zip -0`
store, no compression — DXT/gr2 don't compress and we want fast random access; paths stay
`assets/…` `bgm/…` so they mount as `res://assets/…` `res://bgm/…`).
- `project/asset_pack.gd` (`class_name AssetPack`) — `ensure()` finds the zip
(`MT_ASSETS_ZIP` env → `user://assets.zip` → `OS.get_user_data_dir()/assets.zip` → exe-dir →
`res://../assets.zip`) and `ProjectSettings.load_resource_pack()`s it. `client_main.gd`
calls it in `_ready()` before `AppFlow.start()`.
- `project/asset_root.gd` — adds `res://assets` as the first candidate **iff**
`res://assets/asset_index.txt` exists (the "pack mounted" sentinel; the empty
`project/assets/.gdignore` stub doesn't count).
- Deploy: `./export-android.sh Debug --install` pushes both the APK and (if present)
`assets.zip` → `/sdcard/Android/data/org.internal.mtgodotpoc/files/assets.zip` over USB.
iOS later: `ios-deploy --bundle_id … --upload assets.zip --to Documents/`. Same mount code.
- **Future (download instead of push):** only `AssetPack._find_local_zip()`'s failure branch
changes — HTTPRequest `ASSET_PACK_URL` → `user://assets.zip` (resume + sha256 + progress UI)
→ same `load_resource_pack`. Structurally additive.
- One more `std::filesystem` scan turned up: `fmt::PropertyRegistry::scan()` walks
`<root>/Property/` for `.pr*` CRC files (drives building/tree placement). Added
`PropertyRegistry::scan_list(root, rel_paths)` + `AssetResolver::all_rel()`; `metin2_world.cpp`
feeds it the resolver's file list (both index-loaded and dir-scanned modes). `scan()` kept
for the `formats.map_formats` CTest.
- **Verified end to end**: force-mount the real `assets.zip` (2.1 GB, 58183 entries) with the
loose tree hidden → `Metin2World.load_map("OutdoorA1/metin2_map_a1")` →
20/20 chunks, 20 splatted, **601 objects (0 missing), 368 trees, 1330 property CRCs**, water,
env — byte-for-byte the same result as the loose tree. Desktop unaffected. ctest 10/10,
GDScript 34/34. Android `.so` + iOS `.a` rebuilt.
**Pending:** the OnePlus 13 is on USB and macOS sees it (`ioreg` shows vendor "OnePlus"),
but `adb devices` is empty → enable **Developer options → USB debugging** on the phone and
accept the "Allow USB debugging?" RSA prompt (USB mode: File transfer / MTP). Then:
- `./export-android.sh Debug --install` (or `adb install -r build/export/mtgodot-poc.apk`
then `adb shell am start -n org.internal.mtgodotpoc/com.godot.game.GodotApp`).
- `adb logcat -s godot GodotError Godot` — first run WILL need shader / GPU-skin fixes
under Godot's mobile Vulkan path (never exercised); capture a frame for F3.
- adb note: a stale `sdk/platform-tools/adb` daemon can hold `:5037` ("Address already in
use"); `pkill -9 -f adb` then use one adb consistently (`/opt/homebrew/bin/adb`).
## Notes / gotchas
- The `ranlib: ... has no symbols` spam during a libsodium build is harmless: the
x86 SIMD translation units compile to empty objects on arm64 (their bodies are
guarded by CPU-feature macros). libsodium-cmake compiles the full file list on
every arch by design.
- The staged `project/bin/ios/libgodot-cpp.*.a` is ~430 MB in Debug (all classes,
unstripped). Fine for a dev link; Release + dead-strip shrinks it hard.
`project/bin/ios/` and `project/bin/android/` are gitignored.
- Shaders (`SRC_TERRAIN/WATER/LEAF/SKIN/MIX` + the §2.7 spec branch) and the
GPU-skin bone-texture path have **not** been exercised under Godot's Mobile
renderer yet — that's a separate verification pass once a device boots.
- `net_stream.cpp` is pure POSIX sockets → fine on iOS/Android/Linux/macOS; only
a Windows target would need a Winsock shim.
## GPU texture compression (F4)
`extension/src/texture_util.{h,cpp}` — `make_color_texture(w,h,rgba,len,mipmaps)`
is the single funnel for "decoded RGBA8 → `ImageTexture`". On a mobile OS (or
`MTGODOT_TEXCOMP=1`) it runs the decoded colour image through runtime **ASTC 8x8**
(~2 bpp vs 32 for RGBA8), falling back to RGBA8 if the encoder is unavailable.
Desktop default is **off** and byte-identical to the old per-site code.
Wired: character skins (`metin2_model::_load_dds`), building albedo
(`static_object`), tree bark / leaf-composite (`tree_placeholder`). **Not** wired
(exact texel values matter): splat control maps, `shadowmap`, water, HUD
`load_dds` / minimap.
`texcomp_set_enabled(bool)` overrides at runtime. Verified: macOS + iOS compile,
`ctest` 9/9, desktop character render unchanged, `MTGODOT_TEXCOMP=1` render OK.
Remaining: on-device quality check + VRAM/bandwidth numbers (needs F1/F2).
## App lifecycle (F5)
`project/app_lifecycle.gd` — an `AppLifecycle` node. Add it early (or as an
autoload); `bind(m2client, audio)` wires the common consumers. It turns the
SceneTree-forwarded MainLoop notifications into signals
(`paused` / `resumed` / `focus_changed(bool)` / `memory_warning` /
`back_requested` / `close_requested`) and, by default, on background: pauses the
tree, stops BGM, drops `Engine.max_fps` to 8; restores on foreground.
`pause_tree_on_background` / `background_max_fps` are tunable (login screen sets
`pause_tree_on_background = false`).
`M2Client` (C++) handles `NOTIFICATION_APPLICATION_PAUSED/RESUMED` itself →
`suspend()` / `resume()`. While suspended `_process()` doesn't pump the socket.
A mobile OS drops the TCP connection within ~30 s of backgrounding, so the first
pump after `resume()` surfaces `disconnected`; `reconnect()` redoes the full
auth→game login from stored credentials. `login.gd` auto-calls it on `resumed`
when the stage was in-game. New signals: `suspended`, `resumed`.
Rendering-context loss (Android Vulkan surface) is handled by Godot; every GPU
resource we hold (bone textures, MultiMesh, ShaderMaterials, ImageTextures) is
RenderingServer-managed and survives — nothing for the extension to do.
Verified: headless smoke (`suspend`/`resume`/`reconnect` bound, idempotent,
signals fire once), `ctest` 9/9, iOS compile. Remaining: on-device background/
resume/reconnect cycle and a real iOS memory-warning.
File diff suppressed because it is too large Load Diff
+70
View File
@@ -0,0 +1,70 @@
# Third-party native dependencies
The GDExtension links three native libraries that are **not** part of Godot or
godot-cpp. Phase 1 (macOS) pulled them from Homebrew; that stops working the
moment we cross-compile for the Android NDK or the iOS SDK (BACKLOG F1/F2), so as
of 2026-08-30 all three are built from source as part of our own CMake build.
Everything lives under `extension/third_party/` and is wired by
`extension/third_party/CMakeLists.txt`, which exposes three aliases:
| Alias | Backing target | Consumed by |
|-----------------|------------------|------------------------|
| `mt3p::sodium` | `sodium` | `mtnet`, `mtpack`, `mtproto` |
| `mt3p::zstd` | `libzstd_static` | `mtpack` |
| `mt3p::minilzo` | `minilzo` | `mtproto` |
No source file changed: `#include <sodium.h>`, `#include <zstd.h>` and
`#include <lzo/lzo1x.h>` all still resolve (the last via a 1-line shim header,
see below).
## libsodium — via `robinlinden/libsodium-cmake`
* **Submodule:** `extension/third_party/libsodium-cmake` @ `9b2848d`
* itself carries `jedisct1/libsodium` @ `93a7d0d` (libsodium 1.0.20 line) as a
**nested** submodule — clones need `--recursive`.
* **Why the wrapper:** upstream libsodium is autotools-only. This MIT-licensed
wrapper is a pure CMakeLists over an untouched libsodium checkout; it generates
`version.h`, sets `CONFIGURED`, and builds clean for macOS / iOS / Android /
Windows. Used by many mobile projects.
* **Build options we force:** `SODIUM_DISABLE_TESTS=ON`, `SODIUM_MINIMAL=OFF`
(we need `crypto_kx`, `crypto_auth`, `crypto_aead_xchacha20poly1305`,
`crypto_stream_xchacha20`, `crypto_generichash`/BLAKE2b — all outside the
"minimal" set).
* **License:** ISC (libsodium) + MIT (wrapper). Ship-safe.
## libzstd — `facebook/zstd`
* **Submodule:** `extension/third_party/zstd` @ tag **v1.5.6** (`794ea1b0`).
* Built through upstream `build/cmake` with:
`ZSTD_BUILD_PROGRAMS/SHARED/TESTS/CONTRIB=OFF`, `ZSTD_BUILD_STATIC=ON`,
`ZSTD_LEGACY_SUPPORT=OFF`, `ZSTD_MULTITHREAD_SUPPORT=OFF` (the eterpack path
only does single-shot `ZSTD_compress`/`ZSTD_decompress`).
* Target consumed: `libzstd_static` (carries its own public include dir).
* **License:** BSD-3-Clause / GPLv2 dual. Ship-safe under BSD.
## miniLZO — vendored source (not a submodule)
* **Files:** `extension/third_party/minilzo/{minilzo.c,minilzo.h,lzoconf.h,lzodefs.h}`
copied verbatim from **lzo-2.10** (`minilzo.c` sha1 `019debb3…`), plus
`README.LZO`, `COPYING`, `AUTHORS` as required by the LZO license.
* Upstream LZO has no git repo (oberhumer.com tarball only), and miniLZO is a
4-file amalgamation, so it is copied in rather than submoduled.
* **Shim:** `minilzo/lzo/lzo1x.h` is a 1-line `#include "../minilzo.h"` so
`extension/src/proto/proto.cpp` keeps `#include <lzo/lzo1x.h>` unchanged.
miniLZO's API (`lzo_init`, `lzo1x_decompress_safe`, `LZO_E_OK`, `lzo_uint`) is a
strict subset of full LZO and covers everything the CLZO path uses.
* **License:** **GPLv2**. Acceptable for this internal, non-published,
non-commercial project on the same footing as `libgr2/src/oodle1.c` — but LZO
must be removed, replaced, or commercially licensed before any public release
or commercial use. (item_proto/mob_proto are the only LZO consumers; a
clean-room LZO1X decompressor is the eventual fix.)
## Rebuilding / fetching
`./build.sh` auto-runs `git submodule update --init --recursive` if any of the
three (godot-cpp, zstd, libsodium-cmake+libsodium) is missing. miniLZO needs no
fetch.
Homebrew `libsodium` / `zstd` / `lzo` are no longer referenced by the build and
can be uninstalled.
+345
View File
@@ -0,0 +1,345 @@
# Demo 开发方案 —— bgfx 渲染 assets 下的 gr2 资源
> 本文件把 [`PLAN.md`](./PLAN.md) 的 M0 + M1+ M2 拉伸目标)落成一个**可演示的 demo** 的文件级施工图。
> demo 只做 macOSMetal),跑通后 iOS/Android 是同一套代码换 toolchain。
> 前置事实、坐标/格式细节、风险都以 `PLAN.md` 为准,这里不重复论证。
---
## 1 · Demo 目标与演示脚本
**一句话**:一个原生 macOS 窗口,直接从 `m2dev-client-main/assets/` 读一个 `.gr2` + 它的 `.dds`,用 bgfx/Metal 把这个带蒙皮的角色渲染出来,轨道相机可转,能切线框 / 法线可视化;拉伸目标是让它播一个 `.msa` 动作。
**演示脚本(给人看的顺序)**
| 步骤 | 屏幕上 | 证明了 |
|---|---|---|
| 1 | 终端跑 `gr2dump warrior_cheongrin.gr2`,打印骨架树(骨骼名 + 父索引 + 层级缩进)、网格摘要(顶点数 / 索引数 / 三角组)、动画列表 | libgr2 能脱离 Granny 读结构 |
| 2 | 窗口出现,warrior 网格以**绑定姿势**显示,白模,轨道相机可拖转、滚轮缩放 | gr2 顶点 / 索引 / 骨架 → bgfx,交接成立 |
| 3 | 按 `T` 贴上 `warrior_cheongrin.dds`(DXT3 软解),材质正确 | DXT 解码 + texture-stage + UV 正确 |
| 4 | 按 `N` 切法线可视化、按 `W` 切线框 | 自检工具,抓法线翻转 / 拓扑错 |
| 5(拉伸)| 按 `Space``action/dance_1.gr2` + `dance_1.msa`,角色跳舞,CPU 蒙皮 | 曲线采样 + LBS 成立,M2 的核心 |
| 6 | 角标 HUD 显示 FPS / draw call / 三角数(bgfx 自带 `showStats` | 有性能可读数 |
**不做**:地形、特效、UI、多部件装配、eterpackdemo 用散装文件)、iOS/Android(同代码后续换 toolchain)。
---
## 2 · 演示用资产(具体文件,散装,无需解包)
| 用途 | 路径(相对 `m2dev-client-main/` | 事实 |
|---|---|---|
| 主模型 | `assets/PC/ymir work/pc/warrior/warrior_cheongrin.gr2` | 92 770 Bgr2 格式 v68 section**section 全 Oodle1 压缩**(实测)|
| 主贴图 | `assets/PC/ymir work/pc/warrior/warrior_cheongrin.dds` | **512×512 DXT35 mip** |
| LOD 对照 | `warrior_cheongrin_lod_01/02/03.gr2` | 同骨架、减面,用于 M2 的 LOD 一致性检查 |
| 动作(拉伸)| `assets/PC/ymir work/pc/warrior/action/dance_1.gr2` + `dance_1.msa` | 动画单独一个 gr2 + 文本 msa |
| 刚体静态件(对照)| `assets/Zone/ymir work/zone/oxevent/ox_01.gr2` | 无骨骼,验证 rigid 分支 |
> demo 起步**不碰 `.msm`**`warrior_m.msm` 引用 `warrior_novice.GR2` + 61 组头发 + 一堆动作,太复杂)。直接 `warrior_cheongrin.gr2` + 同名 `.dds` 是自足的 mesh+texture 对。`.msm` 解析留到 M2 多部件装配。
**资产接入方式**CMake 里配一个 `XRENDER_ASSET_ROOT` 指向 `../m2dev-client-main/assets`demo 直接 `fopen` / mmap 读,不复制。
---
## 3 · 依赖与仓库骨架
```
xrender-poc/
third_party/
bgfx.cmake/ submodule → github.com/bkaradzic/bgfx.cmake(拉 bx/bimg/bgfx
⚠ pin 到与 shaders.rar 的 .sc 草稿相近的 bgfx 版本
sokol/ 只放 sokol_app.h(单文件,手动 vendor
glm/ submodule 或单目录 vendor
cgltf/ 单头文件(gr2dump 导出 glTF 用)
reuse/
EterImageLib/ 从 ../MobileSource 冻结拷入:DXTCImage.{h,cpp} + StdAfx + 依赖的最小集
EterBase/ (拉伸)CFileBase / CMappedFile —— demo 初期可先用裸 fopen
libgr2/
include/gr2.h
src/gr2_file.cpp header + section table + fixup 重定位
src/gr2_typetree.cpp 自描述 data_type_definition 遍历器
src/gr2_fileinfo.cpp FileInfo 根对象 → skeleton/mesh/material/animation 视图
src/gr2_skeleton.cpp 骨骼数组 + bind pose 自洽检查
src/gr2_mesh.cpp 顶点/索引/三角组/BoneBindings 提取
src/gr2_anim.cpp TrackGroup → 每骨曲线;曲线子类型解码(demo 子集)
src/gr2_decompress.cpp section 解压分派
src/oodle1.c Granny Oodle1 解码(已实现,端口自泄露 SDK;9166/9166 验证通过)
engine/
rhi.h/.cpp ≈150 行 bgfx 薄封装
camera.h/.cpp 轨道相机
skinning.h/.cpp CPU LBS(拉伸)
animation.h/.cpp 曲线采样 + 世界姿势累积(拉伸)
scene.h/.cpp 把 libgr2 视图 → GPU buffer + draw item
formats/
msa.cpp (拉伸)文本 msa 解析
app/
main.cpp sokol_app 回调 + bgfx init + 输入 + demo 状态机
shaders/
varying.def.sc 从 shaders.rar 拷 + 加 a_indices/a_weight
vs_pnt.sc fs_pnt.sc 从 shaders.rar 拷改
vs_pnt_skinned.sc 新写(骨骼矩阵 uniform 数组)
texture_stage.sh 从 shaders.rar 原样拷
compile.cmake shaderc → metal + bin2c
tools/
gr2dump/main.cpp CLI:结构化 dump + glTF 导出
oracle/ Windows-only,见 PLAN §07demo 阶段可先跳,用 Blender io_scene_gr2 当参照)
cmake/
macos.cmake
CMakeLists.txt
```
---
## 4 · 构建(macOS 优先)
```bash
# 1. 拉依赖
git submodule add https://github.com/bkaradzic/bgfx.cmake third_party/bgfx.cmake
cd third_party/bgfx.cmake && git submodule update --init && cd -
# 固定 bgfx 版本(示例):cd third_party/bgfx.cmake/bgfx && git checkout <pinned-tag>
# 2. 配置 + 编
cmake -B build -DXRENDER_ASSET_ROOT=../m2dev-client-main/assets -DCMAKE_BUILD_TYPE=Debug
cmake --build build -j
# 3. 跑
./build/tools/gr2dump/gr2dump "$XRENDER_ASSET_ROOT/PC/ymir work/pc/warrior/warrior_cheongrin.gr2"
./build/app/xrender-demo
```
**`CMakeLists.txt` 要点**
- `add_subdirectory(third_party/bgfx.cmake)` → 得到 `bgfx` / `bx` / `bimg` / `shaderc` 目标。
- 着色器编译:`app/shaders/compile.cmake` 里对每个 `.sc``shaderc -p metal --platform osx`,产物过 `bin2c` 生成 `*.sc.bin.h`,作为 `xrender-demo` 的生成依赖。
- `reuse/EterImageLib` 编成静态库 `xr_eterimage``-Wno-*` 压掉老代码告警;可能要 `-DXR_STANDALONE` 剥掉 `StdAfx.h` 里的 Windows include。
- macOS`-framework Cocoa -framework Metal -framework QuartzCore``main.cpp` 编成 `.mm`sokol_app 的 macOS 后端要 ObjC)。
---
## 5 · 模块与文件清单(职责 + 估行)
### libgr2demo 子集,只读)
| 文件 | 职责 | 估行 | demo 边界 |
|---|---|---|---|
| `gr2_file.cpp` | magic`GRNFileMV_Old`+ `grn_file_header` + section 表(**已实现**9166/9166 跑通)+ fixup 重定位 | 250 | v6 / 32-bit LE |
| `gr2_decompress.cpp` + `oodle1.c` | section 解压分派 + Granny Oodle1 解码器 | 40 + 450 | **已实现**:从泄露 SDK 端口解码路径,9166/9166 展开到精确 `ExpandedDataSize`,内嵌串验证 |
| `gr2_typetree.cpp` | 按 `granny_data_type_definition`(文件内嵌,自描述)递归走类型树,把裸内存映射成可访问的字段 | 300 | 通用遍历器,不硬编码结构;只需支持 gr2 实际用到的成员类型(Real32 / Int32 / Ref / ReferenceToArray / Inline / String |
| `gr2_fileinfo.cpp` | 定位 `FileInfo` 根对象,暴露 `Skeletons[] / VertexDatas[] / TriTopologies[] / Meshes[] / Materials[] / Textures[] / Models[] / Animations[]` 的 span 视图 | 150 | — |
| `gr2_skeleton.cpp` | 骨骼数组(`Name / ParentIndex / LocalTransform(SRT) / InverseWorld4x4`);**bind pose 自洽检查**:重建 `world_bind`,验 `world_bind[i]·InverseWorld4x4[i]≈I` | 180 | — |
| `gr2_mesh.cpp` | 顶点(识别 `PNT332` / `PNT3322` / 带 `BoneWeights+BoneIndices` 的蒙皮变体)、索引、`TriGroups`(按材质分段)、`BoneBindings`mesh→skeleton 骨骼名映射) | 220 | 只支持上述三种顶点布局 |
| `gr2_anim.cpp` | `Animation → TrackGroups → TransformTracks`;每骨 position/orientation/scaleshear 曲线;曲线解码 + `Animation::sample_local(t)` | 320 | **已实现(全覆盖)**M0 实测 Metin2 曲线**全部**是 `OldCurveType`Granny 2.4`{Degree; Knots[]; Controls[]}`,无压缩变体)。degree 0(常量)/ 1(线性)/ 2(二次 B 样条)+ 四元数归一。**degree 3 全样本 0 个**,无需烘焙退路。精度待 oracle 层① 对拍 |
`include/gr2.h` 对外暴露纯 POD 视图(`gr2::Skeleton` / `gr2::Mesh` / `gr2::Animation`),不泄露内部指针 —— 上层只依赖这个头。
### reuse/EterImageLib(冻结拷入)
- `DXTCImage.{h,cpp}``LoadHeaderFromMemory` + `LoadFromMemory` + `Decompress(level, DWORD* out)` → RGBA8。demo 用 DXT3 分支。
- 依赖裁剪:把 `StdAfx.h` 换成一个最小 shim`typedef uint8_t BYTE` 等),去掉 `windows.h`
- 备选:DXT3 解码就 ~150 行,嫌 EterImageLib 依赖脏可以自己写一个 `dxt.cpp`
### engine/rhi.{h,cpp}(≈150 行)
对齐 `StateManager` 语义的 bgfx 薄封装:
```cpp
namespace rhi {
void init(void* nativeWindowHandle, int w, int h); // bgfx::PlatformData + bgfx::init
void resize(int w, int h);
void beginFrame(const glm::mat4& view, const glm::mat4& proj);
Handle createVB(const void* data, uint32_t size, const bgfx::VertexLayout&);
Handle createIB(const void* data, uint32_t size, bool i32);
Handle createTex2D(const void* rgba, uint16_t w, uint16_t h, uint16_t mips);
Handle createProgram(const uint8_t* vs, uint32_t vsLen, const uint8_t* fs, uint32_t fsLen);
void setModel(const glm::mat4&);
void setBones(const glm::mat4* mtx, uint16_t n); // uniform 数组,vs_pnt_skinned 用
void setTexture(Handle);
void setStageUniforms(const StageDesc&); // texture_stage.sh 的 uniform
void submit(Handle vb, Handle ib, Handle prog, uint64_t state);
void endFrame();
}
```
### app/main.cpp
- `sokol_app` 描述:`.window_title``.high_dpi=true`、macOS 后端;**不让 sokol_app 建 GL/Metal 设备** —— 用 `sapp_metal_get_layer()` / `sapp_macos_get_window()` 取原生 handle 传给 `rhi::init`(这就是 PLAN §03 的交接点,D2 的验收)。
- `frame_cb`:更新相机 → `rhi::beginFrame` → 遍历 scene draw items → `rhi::submit``bgfx::frame()`
- `event_cb`:鼠标拖 = 轨道、滚轮 = 缩放、键 `T/N/W/Space`
- demo 状态机:`enum { BindPose, Textured, NormalViz, Wireframe, Animating }`
### app/shaders
| 文件 | 来源 | 改动 |
|---|---|---|
| `varying.def.sc` | shaders.rar 拷 | 加 `int4 a_indices : BLENDINDICES;` `vec4 a_weight : BLENDWEIGHT;` |
| `vs_pnt.sc` / `fs_pnt.sc` | shaders.rar 拷 | `vs_pnt``a_color0``fs_pnt` 加 fog(demo 可先关);确认 `#include <bgfx_shader.sh>` 对上 pin 的 bgfx 版本 |
| `vs_pnt_skinned.sc` | 新写 | `mat4 skin = u_bones[a_indices.x]*a_weight.x + …`4 权重);其余同 `vs_pnt` |
| `texture_stage.sh` | shaders.rar 原样 | 不改;demo 里给它喂"单 stage MODULATE(TEXTURE, DIFFUSE)"的 uniform,等价于 `tex * vertexColor` |
| `fs_normalviz.sc` | 新写(10 行) | `gl_FragColor = vec4(v_normal*0.5+0.5, 1)` |
### tools/gr2dump/main.cpp
- 参数:`gr2dump <file.gr2> [--gltf out.glb]`
- stdout:骨架树(缩进)、每 mesh 的顶点/索引/三角组/顶点布局、每 animation 的时长 + track 数 + 曲线子类型直方图、bind pose 自洽检查结果(PASS/FAIL + max 偏差)。
- `--gltf`:用 cgltf 写骨架 + 第一个 mesh 的 bind pose + (若实现了)第一个 animation。**仅供 Blender 目视**,动画正确性不认它(PLAN §06 M0)。
---
## 6 · 实现顺序(每步一个可演示切片)
| 步 | 交付 | 演示点 | 验收 |
|---|---|---|---|
| **D0** | CMake 骨架编过:空 `xrender-demo` 开一个 bgfx 清屏窗口(纯色)+ `bgfx::showStats` | 窗口出现、FPS 角标在跳 | bgfx 经 sokol_app handle 起来了(**PLAN §03 交接子门禁的最小版**) |
| **D1** | `gr2_file` + `gr2_typetree` + `gr2_fileinfo` + `gr2_skeleton``gr2dump` 能打印 `warrior_cheongrin.gr2` 骨架树 + mesh 摘要 | 终端 dump 输出 | 骨骼数 / 名称 / 父索引与 Blender `io_scene_gr2` 导入一致;bind pose 自洽 PASS |
| **D2** | `gr2_mesh` + `engine/scene` + `rhi` + `vs_pnt/fs_pnt`warrior **白模绑定姿势**上屏,轨道相机 | 能转的白模 | 轮廓 = Blender 里同模型;无背面/法线翻转(配合 D4 的法线可视化确认) |
| **D3** | `reuse/EterImageLib` DXT3 解码 → `rhi::createTex2D``texture_stage.sh`;按 `T` 贴图 | 有材质的 warrior | UV 无错位、无镜像;和客户端截图目视一致 |
| **D4** | `fs_normalviz` + 线框 state`BGFX_STATE_PT_LINES``BGFX_DEBUG_WIREFRAME`);`N` / `W` 切换 | 法线彩图 / 线框 | 法线朝外;三角组分段正确 |
| **D5(拉伸)** | `gr2_anim`(子集)+ `formats/msa` + `animation` + `skinning`CPU LBS+ `vs_pnt_skinned``Space``dance_1` | 角色跳舞 | 与 Blender 导入的同一动画逐帧目视一致;无肢体飞出/顶点塌陷。数值对拍要 oracle(见 §8) |
**D0D4 = 可交付的 demo**(静态带贴图 + 自检工具)。D5 是加分项,卡住不影响 demo 成立。
---
## 7 · 关键实现细节
### 7.1 gr2 v6 文件结构(`gr2_file.cpp`
```
0x00 BYTE magic[16] // B8 67 B0 CA F8 6D B1 0F 84 72 8C 7E 5E 19 00 1E ← 认版本/字节序
0x10 u32 headerSize // 0x1B8
0x14 u32 headerFormat // 0(这不是 section 压缩!section 压缩看每段 grn_section.Format
0x18 u32 reserved[2]
--- GrannyFileHeader ---
0x20 u32 version // 6
0x24 u32 totalSize // == 文件大小,用来自检解析对齐
0x28 u32 crc32
0x2C u32 sectionArrayOffset // 0x38
0x30 u32 sectionArrayCount
0x34 u32 rootObjectTypeSection / rootObjectTypeOffset / rootObjectSection / rootObjectOffset
...
--- Section[sectionArrayCount] --- 每项 ~44 B
u32 Format // 0=none 1=Oodle0 2=Oodle1Metin2 实测全 2
u32 dataOffset, dataSize
u32 expandedDataSize
u32 alignment
u32 first16Bit / first8Bit // marshalling 边界
u32 pointerFixupArrayOffset, pointerFixupArrayCount
u32 mixedMarshallingFixupArrayOffset, mixedMarshallingFixupArrayCount
```
**流程**:读 header → 逐 section`gr2_decompress` 展开到 `expandedDataSize` 的缓冲 → 应用 pointer fixup(把文件内偏移改写成进程内指针)→ (小端机上 mixed-marshalling fixup 可跳过,big-endian 才需要)→ 得到一组可随机访问的 section 内存块。root object 在 `(rootObjectSection, rootObjectOffset)`,其类型定义在 `(rootObjectTypeSection, rootObjectTypeOffset)`
### 7.2 自描述类型树(`gr2_typetree.cpp`
`granny_data_type_definition` 是数组,每项:`{ MemberType(u32), Name(char*), ReferenceType(def*), ArrayWidth(i32), Extra[3], Ignored }`,以 `MemberType==0`End)结尾。`MemberType` 枚举含 `Inline / Reference / ReferenceToArray / ArrayOfReferences / Real32 / Int32 / UInt32 / String / Transform / …`
写一个 `walk(void* obj, const TypeDef* type, Visitor&)`:按成员类型算 stride、递归 `Reference`/`ReferenceToArray`。**不要硬编码 struct 布局** —— 不同 Granny 小版本字段顺序会变。上层 `gr2_fileinfo` 按**成员名**`"Skeletons"`, `"Meshes"` …)取字段,用 `GrannyFindMatchingMember` 式的按名查找。
### 7.3 顶点布局 → `bgfx::VertexLayout``gr2_mesh.cpp` + `scene.cpp`
gr2 mesh 的顶点类型在文件里(`GrannyGetMeshVertexType`)。demo 支持:
| gr2 顶点类型 | 成员 | bgfx layout |
|---|---|---|
| `PNT332` | Pos3f, Norm3f, UV2f | Position/Normal/TexCoord0 |
| `PNT3322` | Pos3f, Norm3f, UV2f, UV2f | + TexCoord1 |
| 蒙皮变体 | + BoneWeights(4×u8 归一) + BoneIndices(4×u8) | + Weight/Indices`bgfx::Attrib::Weight` + `Indices``AttribType::Uint8`, normalized=weight true / indices false|
`GrannyCopyMeshVertices(mesh, dstType, dstBuf)` 的效果自己实现:按源类型逐顶点拷到一个 demo 统一的打包结构,再 `bgfx::createVertexBuffer`。索引:gr2 是 u16 或 u32`GrannyCopyMeshIndices` 同理。三角组 `TriGroups``(materialIndex, triFirst, triCount)`,每组一次 `rhi::submit`
### 7.4 DXT3 → bgfx 纹理(`scene.cpp`
demo 走**运行时软解**PLAN §08:移动端无 S3TC,且要和 oracle 对齐时贴图要能预解码):
```
CDXTCImage img;
img.LoadHeaderFromMemory(ddsBytes); // 认 512x512 DXT3 5mip
img.LoadFromMemory(ddsBytes);
std::vector<uint32_t> rgba(w*h);
img.Decompress(0, rgba.data()); // level 0mip 链 demo 可先不传,让 bgfx 不采样 mip
rhi::createTex2D(rgba.data(), 512, 512, 1);
```
`bgfx::createTexture2D` + `BGFX_SAMPLER_MIN_POINT` 之类先关 mip,D3 通过后再补全 mip 链。)
### 7.5 坐标系(`scene.cpp` / `camera.cpp`
PLAN §07**约定写死**。demo 取"左手 Y-up、单位 = gr2 原始单位、根变换已 apply"。gr2 里骨架/网格是 Granny 约定(通常右手 Z-up 或文件指定的 art tool basis)。demo 先加一个固定 `basisFix`(可能是绕 X -90° + Z 翻转,M0 用朝向明确的资产标定),乘进 model 矩阵。**左右手系**bgfx 用 `bx::mtxLookAt` / `bx::mtxProj``bx::Handedness` 参数统一,和 `basisFix` 一起调到"warrior 正着站、面朝 +Z"。
### 7.6 bgfx 提交循环(`main.cpp` / `rhi.cpp`
```cpp
bgfx::setViewRect(0, 0,0, w,h);
bgfx::setViewClear(0, BGFX_CLEAR_COLOR|BGFX_CLEAR_DEPTH, 0x303030ff, 1.0f);
bgfx::setViewTransform(0, &view, &proj);
// per draw item:
bgfx::setTransform(&model);
bgfx::setVertexBuffer(0, vb);
bgfx::setIndexBuffer(ib);
bgfx::setTexture(0, s_texColor, tex);
bgfx::setUniform(u_stageColor, &stage, 1);
if (skinned) bgfx::setUniform(u_bones, bones, boneCount);
bgfx::setState(BGFX_STATE_WRITE_RGB|BGFX_STATE_WRITE_A|BGFX_STATE_WRITE_Z
|BGFX_STATE_DEPTH_TEST_LESS|BGFX_STATE_CULL_CW
|BGFX_STATE_MSAA);
bgfx::submit(0, prog);
// end:
bgfx::frame();
```
深度范围 / Y 翻转:用 `bgfx::getCaps()->homogeneousDepth``originBottomLeft` 决定 `bx::mtxProj` 参数,别手写。
### 7.7 sokol_app ↔ bgfx 交接(`main.cpp`D0 的验收)
```cpp
// sokol_app 里关掉它自己的渲染循环意图,只要窗口 + 事件
bgfx::PlatformData pd{};
pd.nwh = sapp_macos_get_window(); // NSWindow*
pd.ndt = nullptr;
// Metal: 也可以 pd.nwh = (__bridge void*)sapp_metal_get_layer(); // CAMetalLayer*
bgfx::Init init;
init.type = bgfx::RendererType::Metal;
init.platformData = pd;
init.resolution.width = sapp_width();
init.resolution.height = sapp_height();
bgfx::init(init);
```
若这条在 macOS 上出不了画面(sokol_app 和 bgfx 抢 layer)——按 PLAN §03 退路:`app/` 换成 SDL2 窗口,`SDL_GetWindowWMInfo``NSWindow*`,其余不变。**D0 就是来验证这个的**,别拖。
---
## 8 · demo 阶段相关的坑(从 PLAN §08 摘)
| 坑 | 在 demo 里的表现 | demo 阶段怎么办 |
|---|---|---|
| sokol_app + bgfx 抢 context | D0 黑屏 / 崩 | D0 卡死就切 SDL2,别硬啃 |
| `.sc` 草稿针对某 bgfx 版本 | shaderc 编不过 / uniform 名对不上 | submodule pin 到相近版本;编不过就照 `bgfx_shader.sh` 手改宏 |
| DXT 软解 vs 客户端 GPU S3TC | D3 贴图和客户端截图有细微色差 | demo 目视够了;要数值对拍时两侧都用软解 RGBA |
| 坐标系 basis | D2 warrior 躺着 / 镜像 / 巨大 | 用朝向明确的资产手调 `basisFix`,记进 `scene.cpp` 注释 |
| ~~曲线子类型超出实现~~ | — | **已消除**M0 实测全 `OldCurveType`degree ≤ 2`gr2_anim.cpp` 全覆盖 |
| `InverseWorld4x4` 读错但一致 | D2 白模看着对,D5 蒙皮炸 | **已兜住**M0 self-check 全量 9166 跑过,`warrior_cheongrin` 6.1e-5 PASS |
| root 偏移放法两种(`InitialPlacement` vs 烘进 root local | 世界姿势整体偏移 ~100 单位 | **已处理**`world[root] = Composite(local[root]) · model.InitialPlacement`M0 gr2_fileinfo 已关联 model→skeleton|
| mediump 精度(移动端才有) | macOS demo 无此问题 | 到 iOS demo 再管,骨骼矩阵用 highp |
---
## 9 · 怎么跑 / 演示检查点
```bash
export XRENDER_ASSET_ROOT="$PWD/../m2dev-client-main/assets"
cmake -B build -DXRENDER_ASSET_ROOT="$XRENDER_ASSET_ROOT" && cmake --build build -j
# 结构(D1
./build/tools/gr2dump/gr2dump "$XRENDER_ASSET_ROOT/PC/ymir work/pc/warrior/warrior_cheongrin.gr2" --gltf /tmp/warrior.glb
# → 期望:骨架树打印、mesh 摘要、"bind pose self-check: PASS (max 3.1e-6)"
# → 把 /tmp/warrior.glb 拖进 Blender,骨架和 T-pose 网格应正常
# 渲染(D2D4
./build/app/xrender-demo
# 拖拽转视角;T 贴图;N 法线;W 线框;Space 播 dance_1D5
```
**演示成立的判据**D0D4 全绿 = "bgfx 能从 assets 的散装 gr2 + dds 渲出正确的带贴图 warrior,且有自检工具" —— 这就把 PLAN 的 M1 用一个能给人看的东西证了。D5 绿 = M2 的核心(曲线采样 + LBS)也站得住。
**下一步**demo 之后):接 oracleWindows dump)做 D5 的数值对拍 → 换 iOS/Android toolchain 跑 D2D4 → 按 PLAN M3 补性能采集。
+558
View File
@@ -0,0 +1,558 @@
# Metin2 跨平台渲染引擎 PoC — 完整方案
> 内部研究方案,不对外公开。
> 基于对 `m2dev-client-src-main`、`MobileSource` 及 `m2dev-client-main/assets` 中真实 `.gr2` 资源的实测编写。
> 所有版本号、格式偏移、API 数量均来自源码与文件抽查。
| | |
|---|---|
| 状态 | PoC 规划 |
| 技术栈 | C++20 · **bgfx**RHI · sokol_app(仅窗口/输入) |
| 目标平台 | macOS · iOS · Android(并行) |
| 判定周期 | ≈ 23 个月 → go / no-go |
| 复用 | EterBase · EterPack · EterImageLib · `MobileSource``.sc` 着色器草稿 |
| 自研 | libgr2gr2 v6 读取器) |
> **选型变更记录**RHI 从 sokol_gfx 改为 **bgfx**。原因见 [§03](#03--技术选型)——`MobileSource` 里已存在一套面向 bgfx 的 Metin2 着色器草稿,bgfx 更 shipping 级、Metal/GLES/Vulkan 更成熟,且这不是一次性 PoC 而是最终移植的同一条代码路径。窗口/主循环/输入计划用 `sokol_app.h` 并把原生 handle 经 `bgfx::PlatformData` 交给 bgfx —— **这个交接不是 sokol_app 常规用法,是一处真风险**,M1 独立子门禁验证,啃不下退回"桌面 SDL2 + 移动端各写最小原生壳"[§03 窗口层](#窗口层sokol_app-与-bgfx-的交接是一处真风险))。
---
## 01 · 目标与判定标准
PoC 不是"移植一小部分客户端",而是把跨平台方案里所有真正未知的环节压到一条最短路径上,跑通即视为整体可行。
### 必须证明的四件事
1. **脱离 Granny 读 gr2** — 自研加载器能从 Metin2 真实的 `.gr2` 中读出骨架、蒙皮网格、骨骼权重与动画轨道。
2. **动画与蒙皮正确** — 骨骼动画采样 + 线性混合蒙皮的结果,与 Windows 客户端逐帧数值一致。
3. **跨平台 RHI 成立** — 同一套渲染代码经 bgfx 在 macOS + iOS 真机 + Android 真机上出画面,且三端一致。
4. **移动端性能达标** — 基准场景在中端机上达到目标帧率、显存与启动时间。
### PoC 明确排除
地形与户外场景、特效与粒子、UI 与 Python 脚本层、网络、阴影、水面、天空盒、LOD 切换策略、多角色 AI。
这些都不含跨平台的新风险,属于后续正式移植的工作量而非可行性问题。
### 判定
PoC 的价值是**降低不确定性**,go 和 no-go 都是有效产出。
> **进度(2026-08-29**M0 完成(9166/9166 解析零崩溃)。M1/M2 代码完成,
> **数值保真已对拍真 Granny 2.9.12**(骨骼世界矩阵 + 蒙皮顶点 ≤ 6.5e-5,见 `oracle/`)。
> M3 iOS 端已交叉编译 + 模拟器运行 + 渲染与桌面一致;Android 脚手架待 NDK + 真机。
> **PoC 的核心技术问题(能否脱离 Granny 正确读 + 播 Metin2 gr2)= 已用数值证据回答「能」**。
> 剩余是真机验证(性能 / 生命周期 / mediump+ Android 端。
> **画质改进 pass(档 1 + 档 2)已完成**MSAA x4 / mip 链 / sRGB 线性着色 / 半球环境 + 3 方向光 / 双面;
> libgr2 加 `granny_material` 链解析 → 逐 tri_group 正确贴图(原来整模型套一张瞎猜的 dds)+ alpha-test 镂空。
> 无回归(gr2fuzz 9166 · render_fuzz 9166 · oracle 23/23)。细节见 `docs/steps/M1-static-render.md`「画质改进 pass」。
- **里程碑 M3 全部门禁通过** = "C++ + bgfx、复用 EterGrnLib 逻辑" 的跨平台方案可行,进入正式移植规划。
- **门禁失败要区分两类**
- **工程性延期** —— 数值差 5%、某个曲线子类型没实现、某平台一个 API 用错。这不是"方案不行",是一个有明确修法、几天到两周能解的 bug。记下来继续。
- **方案性死路** —— 例如资产大量用 BitKnit 且烘焙退路也不可行、或 EterGrnLib 的动作混合逻辑无法脱离 D3D 复刻。这才触发"转 ozz-animation / Rust 重写 / 放弃"的评估。
- 最终交付一页纸结论,明说是哪类、卡在哪层。
### 定位澄清:这不是"跨平台版的 JTX Graphics"
两者是正交的两个轴,不要混淆:
| | JTX Graphics(及同类视觉 mod | 本方案 |
|---|---|---|
| 改的是 | **画质**:程序化天气/昼夜/天空、后处理 | **平台**Granny→libgr2、D3D9→bgfx、Win32→跨平台壳 |
| 平台 | 仍是 Windows / D3D9 / HLSL,焊死 | macOS / iOS / Android |
| 画面目标 | 比原版更好看 | 和原版一样(先求跑对,不追求提升) |
| 一句话 | 同平台,换皮升级 | 同画质,换平台 |
关系:本方案的核心动作就是把 Metin2 的固定管线换成可编程着色器(bgfx `.sc`)。做完之后,再叠一层类 JTX 的视觉增强会容易得多,而且因为着色器经 `shaderc` 编到 Metal/GLES/Vulkan**任何视觉增强天生就是三端的**。正确顺序是"先重构后端(本方案)→ 再叠视觉层";反过来(先在 D3D9 上堆画质再想跨平台)是死路。JTX Graphics 本身闭源付费、无公开源码,不纳入技术选型。
---
## 02 · 架构总览
分层清晰、复用边界明确:底层文件与资产解码搬用 MobileSource 的对应代码(注意那套代码**写了但从未链接成功**,M1 是它第一次真正跑),中间的 gr2 解析全部自研,上层渲染走单一 RHI(bgfx)。
### 分层
| 层 | 内容 |
|---|---|
| 平台壳 | `sokol_app.h`(窗口 / 主循环 / 输入,四平台一份) → 原生 handle 经 `bgfx::PlatformData` 交给 bgfx(交接是风险,见 [§03](#窗口层sokol_app-与-bgfx-的交接是一处真风险) |
| RHI | **bgfx**(经 `bgfx.cmake`Metal / GLES3 / Vulkan / D3D11 · `shaderc``.sc` → 多 profile · RHI 薄封装 ≈ `StateManager` |
| 渲染器 | 场景·相机 / 动画采样 / 蒙皮 CPU→GPU / 材质·贴图绑定 |
| 资产 | **libgr2(自研)** / EterImageLib(复用) / msm·msa 解析 / EterPack(复用,M3 前或 M4 接入) |
| 基础 | EterBase`CFileBase` / `CMappedFile`(复用) / 数学:glm |
### 数据流
```
.gr2/.dds/.msm/.msa → libgr2 · EterImageLib · msm/msa → 中间表示(骨架·网格·曲线·材质)
→ 动画采样 → 世界姿势 → 蒙皮 → RHI(bgfx) → Metal / GLES3 / Vulkan
```
### 复用 / 自研 / 替换
| 模块 | 处理 | 说明 |
|---|---|---|
| EterBase | 复用(**M1 首次验证** | `CFileBase` / `CMappedFile` 的 AAsset 分支 MobileSource **写了但没链接过**;CRC32、lzo、内存映射搬过来,M1 第一次真跑,可能有坑。 |
| EterPack | 复用 | eterpack 读取 + LZO 解压 + 解密。PoC 从散装文件起步,M3 前或 M4 再接(打真机时可能提前,见 [§08](#08--风险登记册) 平台行)。 |
| EterImageLib | 复用(**M1 首次验证** | `CDXTCImage`DXT1/3/5 软解到 RGBA8)、`CTGAImage`,Android 解压分支同样没跑过。移动端无 S3TC 硬件支持,运行时解压是必经路径。 |
| `MobileSource` `.sc` 着色器 | 复用为基线 | `shaders.rar/shaders/` 里那套面向 bgfx 的草稿(见 [§04](#着色器来源mobilesource-的-sc-草稿))。lit / textured / 2D 着色器 + `texture_stage.sh` 可直接改;蒙皮 / fog / 顶点色 / 跨平台编译步骤要补。 |
| EterGrnLib | 按源码复刻 | 代码焊死在 `granny_*``D3DXMATRIX` 上,不能直接编。当权威规格:网格→VB/IB 布局、材质调色板、LOD 控制、动作混合、挂点,照它的调用序列在 libgr2 + glm 上重写。 |
| granny2 / Granny SDK | 替换 | 自研 `libgr2`。泄露的 Granny 源码仅作格式参照,不链接、不进仓库产物。 |
| D3D9 / d3dx9 | 替换 | PoC 不碰完整 EterLib 渲染层。着色器从 `.sc` 草稿改,配一层 bgfx 封装。 |
| MSWindow / DirectInput | 替换 | `sokol_app.h` 提供窗口 / 主循环 / 输入,四平台统一;渲染交给 bgfx。 |
> **`MobileSource` 里的两条渲染路径**:一条是手写的 `GrpOpenGL` D3D8→GLES shim(已进 CMake,~40% 文件参与编译,从未链接成功);另一条是 bgfx 路径(未接线,但有更完整的 `.sc` 着色器集)。本方案接续 bgfx 那条路径的着色器成果,不用 shim。
>
> **`reuse/` 是冻结的 vendored 快照 + 本项目的修复提交在其之上**,不是对 `../MobileSource` 的引用 —— 那套代码从没链接过,M1 一定会改它,必须版本可控。同理 `.sc` 着色器也拷进 `app/shaders/` 冻结。
---
## 03 · 技术选型
### 为什么是 bgfx(而不是 sokol_gfx / wgpu
- **最难的活已有人用 bgfx 干了一半** — `shaders.rar` 里那套 `vs/fs_pnt``pdt``pt``terrain` + `texture_stage.sh` + `varying.def.sc` 就是冲 bgfx 写的。选 bgfx = 用 `shaderc` 编一下几乎直接能用;选 sokol = 得把 `.sc` 逐个翻成 sokol-shdc 的 GLSL,还要重写 helper。
- **这不是一次性 PoC** — 目标是真做跨平台客户端,PoC 就是最终移植的同一条代码路径。RHI 选型要背几年,选一个 shipping 级、社区大、Metal/GLES/Vulkan 都成熟的更划算。
- **引擎结构对口** — Metin2 整套是 FVF + texture-stage + 多光源 + alpha-test 的固定管线思维,bgfx 的 vertex layout / `varying.def.sc` / state flags 跟这套心智模型贴得近。
- **调试设施** — 内置 stats HUD、`dbgTextPrintf`、RenderDoc 钩子、`BGFX_DEBUG_*`,正是验证阶段要用的;`bimg` / `texturec` 量产转 ASTC/ETC2 也用得上。
- **构建摩擦已消除** — 用 `bgfx.cmake`(社区维护的 CMake 封装),集成就是 `add_subdirectory` 的事,`bx` / `bimg` / `bgfx` 三个仓库一起拉。
### 窗口层:sokol_app 与 bgfx 的交接是一处真风险
bgfx 自己不管窗口。计划做法:`sokol_app` 拿到原生 window handle → 填 `bgfx::PlatformData``bgfx::init`,让 bgfx 自己建设备。
**但这不是 sokol_app 的常规用法**sokol_app 和 bgfx 都是"整个 app 框架",都想拥有 swapchain / context。sokol_app 在 iOS/Android 会自建 EAGL/EGL context 和 `MTKView` / `GLSurfaceView`,要让 bgfx 接管得专门压制这些(可能要 `SOKOL_NO_ENTRY` + 只用 sokol_app 的窗口/输入部分)。桌面上更稳的组合其实是 **bgfx + SDL2 / GLFW**
因此:**M1 给这个交接一个独立子门禁**macOS 上 bgfx 经 sokol_app 的 handle 出画面 + 输入可用);若两周内啃不下,退回"桌面 SDL2 + iOS/Android 各写最小原生壳"。
### 着色器
`MobileSource/Cross Platform/shaders.rar/shaders/*.sc` 为基线,用 bgfx 的 `shaderc` 编译。
不再手写 sokol-shdc GLSL。详见 [§04 着色器来源](#着色器来源mobilesource-的-sc-草稿)。
- 目标 profile`--platform osx -p metal`macOS)、`--platform ios -p metal`iOS)、`--platform android -p 300_es`Android GLES3)、`--platform windows -p s_5_0`Windows 对照)。
- 产物 `.bin``bin2c` 嵌成 C 数组,`bgfx::createShader` 加载。
- `bgfx_shader.sh` 随 bgfx submodule 自动就位。**`.sc` 草稿写于 2025-12,针对某个 bgfx 版本 —— `bgfx.cmake` 的 submodule 就 pin 到那个或相近版本,否则宏 / uniform 命名可能对不上。**
- `MobileSource` 里的 `compile_shaders.bat` 路径写死、只出 Vulkan 一个 profile**要换成 CMake 的可移植 custom command**。
### 构建
- 单一 `CMakeLists.txt` + 三份 toolchainiOS`ios.toolchain.cmake`)、Android NDK、macOS 原生。
- `third_party/``bgfx.cmake`(含 `bx` / `bimg` / `bgfx` submodule)、`sokol`(仅 `sokol_app.h`)、`glm`
- C++20`-fno-exceptions` 视复用代码情况而定(EterBase 用了少量异常,先保留)。
- 数学库用 **glm**:列主序,与 GL/Metal/bgfx 一致;替换 `D3DXMATRIX` 时注意 D3DX 是行主序,上传前转置或全程列主序。
- CI 三端并行:macOS 原生、iOS 模拟器、Android 模拟器。
---
## 04 · 关键模块
### libgr2 — gr2 v6 读取器(自研,核心)
只读,覆盖:
- **文件头 + section 表** — 定位各段;实现 section 解压(预期未压缩或 Oodle0/1)。
- **自描述类型树遍历** — 不硬编码结构,按文件内嵌的 `data_type_definition` 递归解析,指针/引用重定位(relocation/marshalling 表)。
- **`FileInfo` 根对象** — 取出 `Textures[]``Materials[]``Skeletons[]``VertexDatas[]``TriTopologies[]``Meshes[]``Models[]``Animations[]`
- **骨架** — 骨骼数组(名称、父索引、`LocalTransform``InverseWorld4x4`)。
- **网格** — 顶点(`PNT332` / `PNT3322` 及带骨骼索引+权重的蒙皮变体)、索引、三角组(按材质分段)、`BoneBindings`
- **动画** — `TrackGroups` → 每骨的 position / orientation / scaleshear 曲线;曲线子类型解码(关键帧数组、B 样条拟合等)。
对照实现:泄露 Granny 源码里的 `granny_data_type_definition.*``granny_file_info.*``granny_curve*.cpp` 当算法参照读;不复制代码。
### 资源加载
- **贴图** — `.dds``CDXTCImage` 解到 RGBA8,用 `bgfx::createTexture2D` 上传(移动端显存 ×4,PoC 可接受)。量产阶段换离线转 ASTC / ETC2 或 Basis Universalbgfx 的 `bimg` / `texturec` 就能做)。
- **文件访问** — 直接用 `CMappedFile`,桌面走 mmapAndroid 走 `AAsset_getBuffer`MobileSource 已实现)。M0M3 用散装文件,M4 切 eterpack。
### 动画与蒙皮
- **采样** — 按局部时钟对每骨曲线插值 → 局部变换 → 沿父链累积得世界矩阵(对应 `GrannyBuildWorldPose` / `GrannyGetWorldPoseComposite4x4Array`)。
- **过渡** — ease-in/out 曲线、loop count、raw local clock 的语义,照 `EterGrnLib/Motion*``ModelInstanceMotion.cpp` 的调用序列复刻。
- **蒙皮** — M2 先 CPU 线性混合蒙皮(`skinMatrix = world[bone] * invBind[bone]`,逐顶点 4 权重加权);M4 移到顶点着色器(骨骼矩阵走 bgfx uniform 数组或 texture)。
- **rigid mesh** — `GrannyMeshIsRigid` 为真的网格不做蒙皮,只按挂载骨骼刚体变换 —— 分支照搬。
### 着色器来源:`MobileSource` 的 `.sc` 草稿
`shaders.rar/shaders/` 是一份**没写完的草稿**,能用的部分省事,缺的部分正好是 PoC 核心。
| 文件 | 是什么 | 对我们 |
|---|---|---|
| `varying.def.sc` | bgfx attribute/varying 声明:`a_position/a_color0/a_texcoord0/a_normal``v_normal/v_texcoord0/v_worldPos/v_texcoord1` | 结构直接用,**但没有骨骼索引/权重**,要加 `a_indices` / `a_weight` |
| `vs_pnt.sc` / `fs_pnt.sc` | position-normal-texcoord。FS 是重头:8 光源前向光照 + 全套 `D3DCMP_*` alpha-test + `applyTextureStage` | **lit mesh 着色器基线**M1M2 直接改 |
| `vs_pdt.sc` / `fs_pdt.sc` | position-diffuse-texcoordMetin2 最常用 FVF | 带顶点色的网格 |
| `vs_pc.sc` / `fs_pc.sc` | position-color,无纹理 | UI 图元 |
| `vs_pt.sc` / `pt2``fs_pt.sc` / `pt2` | position-texcoord2D 图像/精灵 | UI 贴图 |
| `vs_terrain.sc` / `fs_terrain.sc` | 地形 splatting,很小 | 基本占位,PoC 不用 |
| **`texture_stage.sh`** | 把 D3D 固定功能 `SetTextureStageState` 完整翻译成 shaderCOLOROP/ALPHAOPMODULATE、MODULATE2X/4X、ADD、ADDSIGNED、SUBTRACT、BLEND…)、arg 选择(TEXTURE/DIFFUSE/CURRENT/TFACTOR)、COMPLEMENT/ALPHAREPLICATE 标志,uniform 驱动 | **最值钱的一块**,但它是一份**未经校验的重实现** —— 见下方验证要求 |
> **`texture_stage.sh` 必须专门验证**:它是别人写的 D3D 固定功能重实现,MODULATE2X 的 clamp、arg complement、多 stage 串联都可能有细微错。M1/M2 要拿**一个已知的多 stage 材质**(从测试资产集里挑一个用了 `D3DTOP_MODULATE2X` 或 `ADDSIGNED` 的)单独和 oracle 对拍,不能只靠整场景 SSIM 兜。
**状态 / 要补的**
- `.bin` 输出大多 31–34 字节,是空/失败产物 —— 忽略,从 `.sc` 重编。
- `compile_shaders.bat` 路径写死、只出 Vulkan 一个 profile —— 换成 CMake custom command,出 metal / 300_es / s_5_0 三套。
- **没有蒙皮** —— 没有骨骼矩阵、没有 `a_indices` / `a_weight`。新写 `vs_pnt_skinned.sc`(骨骼矩阵走 uniform 数组或 textureVS 里做 LBS)。这是 M2 的核心。
- `vs_pnt` 丢了 `a_color0`(Metin2 网格常带顶点色),要补。
- `v_texcoord1`(第二 UVlightmap/detail)声明了没用。
- **没有 fog** —— Metin2 大量用 D3D 雾,要加。
一句话:这套 `.sc` 把"固定管线光照 + alpha-test + texture-stage → shader"做掉了大半,蒙皮、fog、顶点色、跨平台编译步骤要自己补。
### msm / msa — Metin2 包装格式
`.msm` 描述一个模型:引用的 `.gr2`、贴图、材质类型、挂点。`.msa` 描述动作:引用的动画 gr2、混合参数、事件。
格式简单,解析器逻辑就在 `RaceManager.cpp` / `EterGrnLib/Util.cpp` 里,照抄。
**要有验证门**`.msm` / `.msa` 是人可读的文本文件。M2 加一条子检查——dump 解析结果(挂点名、动作列表、混合参数),和源文本逐项目视核对。否则 `.msa` 混合参数读错 → 动画错 → M2 矩阵 diff 失败,你会去 debug libgr2 / 采样,真 bug 却在这里。
### RHI 封装
一层 ≈ 200 行的薄封装,把"设置纹理 / 设置顶点缓冲 / 设置变换 / 画一批索引三角形"映射到
`bgfx::setVertexBuffer` / `setTexture` / `setUniform` / `setState` / `submit`,语义对齐 `StateManager`
目的是让从 `EterGrnLib` 复刻过来的渲染逻辑几乎逐行对应。
### 平台壳
- `sokol_app.h` 的单回调主循环,四平台一份 `main`;原生 handle 经 `bgfx::PlatformData` 交给 `bgfx::init`。**这个交接本身是风险,见 [§03 窗口层](#窗口层sokol_app-与-bgfx-的交接是一处真风险)M1 独立子门禁。**
- **Android 上下文丢失** — 切后台 GL context 连同 GPU 资源可能失效。在 sokol_app 的 `SUSPENDED` / `RESUMED` 事件里按 bgfx 的重置流程处理(`bgfx::reset` + 必要时重建资源)—— **M3 就处理,不要拖到最后**
- **iOS** — 全静态链接,无 `dlopen`Metal drawable 生命周期照 sokol_app 处理;资源打进 `.bundle`
- **资产上真机的方式要在 M3 前定**:9166 个散装 `.gr2` + 一堆 `.dds` 不能直接堆进 APK / `.bundle`(体积、iOS 限制)。要么 M3 前提前接 eterpack(现排 M4),要么只把测试资产集打进去。M3 交付里"原样交叉编译"没算这一步。
---
## 05 · gr2 格式要点
抽查 `warrior_cheongrin_lod_01.gr2` 的文件头:
```
偏移 字节 含义
0x00 B8 67 B0 CA F8 6D B1 0F 84 72 8C 7E … 16 字节 magic:32 位小端 · 文件格式版本 6
0x10 B8 01 00 00 头部长度 = 0x1B8
0x20 06 00 00 00 格式版本字段 = 6
0x24 AC 09 01 00 TotalSize = 68012 = 文件实际大小 ✓ 解析正确
0x28 65 7A 20 53 CRC32
0x2C 38 00 00 00 section 表偏移 = 0x38
```
### 已确认(M0 T1–T9 全部实现并全量实测:9166/9166 解析成功、0 崩溃、0 非退化谓词失败)
> `libgr2` = header/section/fixup + 自描述类型树遍历器 + FileInfo + 骨架(含 bind-pose 自洽检查)
> + 蒙皮网格 + 动画曲线解码 + 采样。`gr2dump` / `gr2fuzz` 见 `tools/`。报告见 `test/fuzz-report.json`。
- **magic = `GRNFileMV_Old`**(不是 `GRNFileMV_32Bit_LittleEndian`),32 位小端。**9155 个是格式 v6,11 个是 v7**(容器兼容,都能读)。
- 每文件固定 **8 个标准 section**Main / RigidVertex / RigidIndex / DeformableVertex / DeformableIndex / Texture / Discardable / Unloaded)。
- **section 用 Oodle1 压缩**(每段 `Format==2`Texture section 例外,空)。之前"未压缩"是把 `HeaderFormat`=0)错当成 section 压缩。**没有 BitKnit**(全样本 0 个)。**Oodle1 解码器已从泄露 SDK 端口进 `libgr2``oodle1.c`),全量验证通过。**
- 资产由 **Granny Standard Exporter SDK 2.4.0.7** 导出 —— oracle 的 `granny2.dll` 应锁 2.4.x 线(见 [`steps/00-oracle.md`](./steps/00-oracle.md) T1)。
- `total_size` 字段 == 文件实际大小;解压后每个 section 恰好 `ExpandedDataSize`。内嵌路径串(`D:\Ymir Work\...`)解压后干净可读 —— 内容正确性的 oracle-free 锚点。
- 客户端共 **9166 个 .gr2**,散装在磁盘,PoC 阶段无需解包。
- 引擎实际用到的 Granny API 约 **90 个**,全部是"读文件 / 采样动画 / 变形顶点",无任何建模或导出。
- 顶点类型实测:`PNT332`rigid4016 mesh)、`PNT3322`rigid 双 UV309)、`PNT332_Skinned`(3118)。**无未知类型**。蒙皮变体带 `BoneWeights`NormUInt8×4 或 Real32×4+ `BoneIndices`UInt8×4 或 packed UInt32)。
- **曲线全部是 `OldCurveType`**Granny 2.4`{Int32 Degree; RefToArray Knots; RefToArray Controls}`**无压缩变体、无 `curve2`/`CurveData` variant**)。degree ∈ {0,1,2}**无 3**)、dim ∈ {0,3,4,9}。`gr2_anim.cpp` 全覆盖(常量 / 线性 / 二次 B 样条 + 四元数归一)→ **§08 的"曲线子类型超出范围"风险消除,无需烘焙退路**。
- **非单位 scaleshear 普遍**2267 个 skeleton 至少 1 骨、全语料 13298 骨带非单位 scale-shear。→ 组合公式走完整 `R3·SS3`(已实现);M2/M3 蒙皮按完整仿射,**不可用 `world·invBind` 的正交近似**。
- **多 skeleton / 多 model**:一个文件可含本体 + 武器挂点各自的 skeleton(如 `redthief2_soldier2` = 3 skeleton / 3 model)。mesh `BoneBindings` 对每个 skeleton 试解析取悬空最少者。
- **root 偏移两种放法**:多数 skeleton 把 model-space 偏移放在 `model.InitialPlacement`root 骨 local 为单位);少数烘进 root 骨 local。世界姿势 = `Composite(local[root]) · InitialPlacement`
- `EterGrnLib` 已用 `#if GrannyProductMinorVersion == 4 / 7 / 8 / 9 / 11` 兼容多版本 —— 实测资产就是 **2.4**,与 libgr2 的选择一致。
### M0 抽样统计结果(`test/fuzz-report.json`,全量 9166
- 格式版本:v6 × 9155、v7 × 11。section 压缩:非空段 100% Oodle1**0 Oodle0 / 0 BitKnit** → §08 BitKnit 退路项不触发。
- bind-pose 自洽(不依赖 oracle):多骨 skeleton 2013/2017 `max|Δ| < 1e-3``warrior_cheongrin` 6.1e-5);4 个 `>=1e-1` 已定位到辅助骨子树的 `InverseWorld4x4` 未随 `InitialPlacement` 更新(含 1 个文件名带 `_backup`),非阻塞。
- 曲线子类型直方图(degree·dim):pos `d0·d3`(322k)/`d2·d3`(41k)rot `d0·d4`(79k)/`d2·d4`(210k)scale `d0·d9`(42k)/`d1·d9`(3k);大量 `d0·dim0`(恒等轨道)。
### M0 第一件事:锁定 oracle 用的 Granny 版本
资产是文件格式 v6(约 2.6–2.9 era),但 `m2dev-client-src` 头文件写 2.11.8。**oracle 必须用实际能正确加载这批资产的那个 `granny2.dll` 版本**去 dump,否则是拿错误的参考去校 libgr2。M0 起手要确认:这批 gr2 在哪个 Granny 版本下 `GrannyGetFileInfo` 返回完全正常(骨骼数、顶点数、动画时长都合理),把那个 DLL 固定进 `oracle/`
---
## 06 · 里程碑与验证门
五个里程碑,每个都有明确的交付物和一道 go/no-go 门禁。单人全职到 M3 结束约 2–3 个月。
里程碑的**验证逻辑**与 RHI 无关;换 bgfx 只在 M1 多了一道"sokol_app + bgfx 交接"的子门禁。
> **分册**:本节是索引,每个里程碑的文件级施工文档在 [`steps/`](./steps/)
> [`00-oracle`](./steps/00-oracle.md)(关键路径,M0 起)·
> [`M0-gr2-reader`](./steps/M0-gr2-reader.md) ·
> [`M1-static-render`](./steps/M1-static-render.md) ·
> [`M2-anim-skinning`](./steps/M2-anim-skinning.md) ·
> [`M3-mobile`](./steps/M3-mobile.md) ·
> [`M4-realistic-load`](./steps/M4-realistic-load.md)。
> M0+M1 的可演示 demo 施工图另见 [`DEMO-PLAN.md`](./DEMO-PLAN.md)。
### M0 · gr2 解析器(独立 CLI) — 1–2 周
- **交付** — ① 锁定 oracle 的 Granny 版本(见 [§05](#m0-第一件事锁定-oracle-用的-granny-版本));② `gr2dump``warrior_*` 系列 + 若干 zone 静态件,产出 libgr2 的结构化 dump(骨架 / 网格 / 动画曲线)**和** glTF;③ 对全量 9166 个 `.gr2` 跑 parse-fuzz,产出格式变体直方图;④ 从 fuzz 元数据里筛出测试资产集(见 [§07](#测试资产集精选约-15-个))并写进 `test/assets.list`
- **门禁** — libgr2 结构化 dump 与 oracle 的 `GrannyGetFileInfo` dump **逐字段一致**(见验证);fuzz 零崩溃且**产物通过非退化谓词**;变体分布落在可实现范围(无 BitKnit,或有明确离线转换退路);测试资产集已产出。
- **验证** —
- **自洽检查(不依赖 oracle,先跑)**:从 `LocalTransform` 链重建 bind pose 世界矩阵,验证每骨 `world_bind[i] · InverseWorld4x4[i] ≈ I`。这一条抓"读矩阵时带了转置 / 手系错、且同样作用于正向读取"这类 oracle 对拍也发现不了的 bug。
- **主**libgr2 结构化 dump vs oracle dump 逐字段对拍 —— 骨骼数 / 父索引 / 名称 / `LocalTransform` / `InverseWorld4x4` / 顶点数 / 索引数 / 每骨曲线的关键帧数 / 动画时长,全部相等或 < 噪声地板。
- **辅**:导出 glTF 在 Blender 目视骨架拓扑 + 绑定姿势网格。**注意 glTF 只能证拓扑和 bind pose,证不了动画曲线解码对**(glTF 动画表达不了 Granny 的 ease 曲线 / scale-shear / 常量轨道压缩)—— 动画正确性只认上面那条主验证。
- **fuzz 非退化谓词**:骨骼数 ∈ [1, 512];每骨父索引 < 自身索引或 = -1;变换无 NaN/Inf;顶点数 > 0;所有索引 < 顶点数;每顶点权重和 ∈ [0.99, 1.01];骨骼索引在范围内。不满足即计一次 fail。
- **bootstrap 关系**:M0 自身验证用硬编码引导集(`warrior_*` + 若干 zone);`test/assets.list` 一旦产出,M1 及之后一律用它。
### M1 · macOS 静态渲染 — 23 周
- **交付** — sokol_app 窗口 + bgfxMetal),渲染 gr2 静态网格(绑定姿势)+ 贴图,轨道相机;着色器从 `vs/fs_pnt` 草稿改;`texture_stage.sh` 接一个已知多 stage 材质。
- **子门禁 1(交接)** — bgfx 经 sokol_app 的原生 handle 在 macOS 出画面 + 输入可用(见 [§03 窗口层](#窗口层sokol_app-与-bgfx-的交接是一处真风险))。啃不下则退回 SDL2 方案。
- **子门禁 2(几何)** — 网格拓扑、UV 正确;无背面剔除错误 / 法线翻转;一个多 stage 材质的着色结果与 oracle 对上。
- **验证** —
- **对比场景两侧都喂同一张预解码 RGBA**(绕开 DXT),让截图 diff 只反映几何 / 光照 / texture-stage,不被软解 vs 硬件 S3TC 的 bit 级差异污染。
- oracle 侧要能进入确定态:固定相机矩阵注入、固定光、绑定姿势、无程序化摇摆 —— 这需要 oracle build 的少量改造,M1 一并做。
- 与 oracle 截图像素 diff + SSIM;开法线可视化模式自检。
### M2 · 骨骼动画 + CPU 蒙皮 — 2–3 周
- **交付** — 解析 `.msm` / `.msa`,构建世界姿势,线性混合蒙皮,播放 idle / walk,支持 loop 与 ease;补 `vs_pnt_skinned.sc`;装配一个**多部件角色**(身体 + 至少一个额外 gr2,如头发/时装)+ 一个**挂点武器**。
- **门禁(分级,按序)** —
0. **`.msm` / `.msa` 解析子检查**:dump 解析结果和源文本逐项目视核对(挂点名、动作列表、混合参数)。
1. **骨骼世界矩阵**与 oracle 的**层 ①(裸 Granny`GrannyGetWorldPoseComposite4x4Array` 直出)**逐帧对拍,max|Δ| < ε_mat —— 隔离"曲线采样 + 世界姿势累积",先过。**M2 只对层 ①**;层 ②(过完 `EterGrnLib``ActorInstanceBlend` + LOD 骨骼裁剪)不在 PoC 范围,留到正式移植。
2. **蒙皮顶点坐标**与 oracle 逐帧对拍,‖Δ‖ < ε_vtx —— 隔离"蒙皮 + 顶点格式"。
3. 多部件装配:挂点武器的世界变换与 oracle 一致(`GrannyFindBoneByName` + 挂点矩阵链复刻对)。
4. **LOD 一致性**:同一模型 LOD 0–3 共享骨骼绑定,切换无跳变(顶点在切换帧的位移 < 阈值)。
- **验证** — 数值层对拍(N 帧 × M 顶点、全部测试资产集,不是单文件;tie-break 见 [§07 数值层](#数值层))+ 视觉层(3 个确定姿势,差异分类见 [§07 视觉层](#视觉层))。
- **动画曲线退路** — 若 M0 统计发现曲线子类型超出可实现范围,改走"oracle 离线烘焙成密集关键帧"(见 [§08](#08--风险登记册) 动画行),libgr2 只读烘焙格式,M2 照常验证。
### M3 · iOS + Android 真机 — 12 周(与 M2 尾段并行)
- **交付** — M2 工程交叉编译(**含资产上真机的方案**,见 [§04 平台壳](#平台壳) —— 提前接 eterpack 或只打测试资产集,不是"原样交叉编译"就完事),两端真机运行基准场景;CI 三端冒烟。
- **门禁** —
- 三端渲染差异分类通过(见 [§07 视觉层](#视觉层));
- 简化角色 1 个 ≥ 60fps、20 个 ≥ 30fps,满配角色 1 个 ≥ 60fps、8 个 ≥ 30fps(机型见 [§07 性能层](#性能层真机));冷启动 < 3s
- 无 bgfx / 图形验证层报错;
- **Android 切后台 ×100 恢复正常(无崩、无黑屏);iOS 切后台 / 锁屏 / 来电后恢复正常(丢 Metal drawable 的处理)**
- **mediump 精度专项**:顶点着色器骨骼矩阵 / 位置用 highp 前后对比,确认无抖动 / 爆顶点。
- **验证** — 三端快照互拍 + 真机性能采集(帧时间 p99、显存、RSS、加载耗时)。
### M4 · 逼近真实负载(可选) — 2 周
- **交付** — GPU 蒙皮;改走 eterpack 加载;多角色 + 一块地面 + 简单光照;LOD 切换。
- **门禁** — GPU 蒙皮结果与 CPU 一致;50 角色可交互帧率;eterpack 路径与散文件结果一致。
- **验证** — CPU / GPU 蒙皮对拍;扩展性能曲线(1 / 8 / 20 / 50 角色)。
---
## 07 · 验证方法论
核心思路:把 Windows 客户端当作"真值预言机"(oracle),新实现的每一层输出都要能和它对拍。分数值、视觉、性能三个层次,配自动化回归。
### 预言机:插桩的 Windows 客户端
在 Windows 侧构建一个精简 harness,链接**锁定版本的** `granny2.dll` + `EterGrnLib`(版本确定见 [§05](#m0-第一件事锁定-oracle-用的-granny-版本)),能对"给定 `.gr2` + `.msa` + 时刻 `t`"导出:
- **两层骨骼矩阵**:① 裸 Granny 层(`GrannyGetWorldPoseComposite4x4Array` 直出);② 过完 `EterGrnLib` 的 LOD 骨骼裁剪 + `ActorInstanceBlend` 混合后的最终矩阵。libgr2 + 我们的采样先对①,装到引擎后对②。
- mesh 0 蒙皮后的顶点坐标;
- 确定态截图(固定相机矩阵注入、固定光、精确 model clock、单动作无混合、关掉待机摇摆 —— 需 oracle build 少量改造)。
**坐标空间必须两侧都写死**oracle dump 和 libgr2 输出都声明用同一约定(左手 Y-up、单位、根变换是否已 apply)。§08 的 basis 转换高危项,就靠这条数值锚点来抓 —— 目视抓不住 90° 轴交换或 ×100 缩放。
**oracle 自身要先过自检**(在被信任之前):加载一个 gr2、dump t=0 的骨骼矩阵,应与该 gr2 存的 bind pose`InverseWorld4x4` 求逆)一致。过不了这条,下游全建在沙子上 —— 这是 M0 的隐性前置门禁。
这套 dump 是 Windows-only,永远留在 Windows。`oracle/` 的搭建(把 `EterGrnLib` 从完整客户端里单独抽出来编、两层矩阵 dump、确定态改造)**从 M0 起,是关键路径**,需要一个会 Windows / D3D 构建的人,[§09 工作量表](#工作量)里单列。
### 数值层
**阈值不是拍的,要先测噪声地板**:把 oracle 跑两遍(或 oracle vs 另一个已知正确的 Granny 实现),看同一输入的输出抖动有多大,ε 设在地板之上一个安全余量。否则正确代码也会 fail —— 60 骨链上 float32 累积、Granny 内部用 float、我们用 double 再转,1e-4 / 1e-3 这种数很可能比噪声还紧。
| 对象 | 隔离的是 | 比对方式 | 阈值 |
|---|---|---|---|
| gr2 结构(骨骼数 / 父索引 / 名称 / `LocalTransform` / 顶点数 / 索引数 / 每骨关键帧数 / 动画时长) | 解析正确性 | libgr2 vs oracle dump vs Blender 导入,三方一致 | 相等(浮点字段 < 噪声地板) |
| 骨骼世界矩阵(先对裸 Granny 层,装进引擎后对 EterGrnLib 层) | **动画采样 + 世界姿势累积** | 逐元素与 oracle 相减,取 max\|Δ\| | < ε_mat(噪声地板 × 余量) |
| 蒙皮顶点坐标 | **蒙皮 + 顶点格式**(在矩阵已对上的前提下) | N 帧 × M 顶点,`‖v_ours v_oracle‖` | < ε_vtx |
**必须按序**:先让骨骼矩阵对上,再看顶点。否则动画错 + 蒙皮错可能相互抵消、最终顶点却"对",掩盖两个 bug。
在整个测试资产集上跑,不是单文件。
**tie-break**:矩阵 / 顶点对拍默认 oracle = 真值。但如果 libgr2 与 oracle 差超过 ε、结果又都合理,需要第三个独立参照来判 —— 在测试资产集上,用 Blender `io_scene_gr2` 计算的世界矩阵(或第二个开源 gr2 库)做仲裁。这也能抓出 oracle 自己的 bug(DLL 版本选错、确定态改造引入坐标错)。
### 视觉层
- **确定性场景** — 固定相机矩阵、固定光、固定姿势(idle 第 0 帧、走路中段、旋转量大的姿势)。让 **oracle** 进入这个确定态需要改 oracle build(注入相机矩阵、精确 model clock、关程序化摇摆、单动作),这块工作在 M1。
- **贴图从对比里剔除** — 对比场景两侧都喂同一张预解码 RGBA。客户端用 GPU 硬件 S3TC、PoC 用 `CDXTCImage` 软解,两者 bit 级有差,会让整张图偏移、SSIM 掉分,原因跟要验证的几何/蒙皮无关。
- **单一 SSIM 阈值不够,要差异分类** — 同一帧 Metal vs GLES 会有"正确但可见"的差别(各向异性过滤强度、mip 选择、gamma)。流程:`SSIM ≥ 0.98` 直接过;`0.950.98` 之间进人工/规则分类,判定差异是否全落在"渲染器合理差异"集合里,是则过、否则 fail;`< 0.95` 直接 fail。
- **三端互拍** — 三个端之间应比各自与 oracle 更接近(同一 shaderc 源、同一逻辑)。
- **失效模式清单** — 法线 / 背面翻转、UV 接缝、绑错骨骼(肢体飞出)、顶点塌陷(权重错)、z-fighting、alpha 排序、gamma / 色彩空间、左右手系镜像、整体缩放错(basis 转换,数值层才是主抓手)。
### 性能层(真机)
- **两种角色都要测** — ① 简化角色(≈ 5k 三角、≈ 60 骨、单 draw call);② **满配角色**(多部件装配 + 武器 + 时装 + 挂点特效,1–2 万三角、多材质多 draw call)。只用简化角色外推真实负载会乐观。每种从 1 个放大到 8 / 20 / 50 个。
- **基准硬件写死具体机型**(不是"级别")—— 例如 iPhone 11A13+ Pixel 6Mali-G78+ 一台 Adreno 机(如 Redmi Note 系列),三种主流移动 GPU 架构各一。
- **指标** — 帧时间 p50 / p99ms)、FPS、draw call、GPU 显存(Xcode GPU report / Android GPU Inspector / `adb dumpsys meminfo`)、冷启动到首帧、单个 `.gr2` 加载耗时、峰值 RSS。
- **PoC 通过线**:简化角色 1 个 ≥ 60fps、20 个 ≥ 30fps**满配角色 1 个 ≥ 60fps、8 个 ≥ 30fps**;单角色加载 < 30ms;冷启动 < 3s;基准 RSS < 300MB。
- M4 加做 CPU vs GPU 蒙皮对比。
### 自动化 / CI
- **`gr2-fuzz`** — 解析全部 9166 个 `.gr2`,不许崩溃,产物通过非退化谓词(骨骼数 ∈ [1,512]、父索引 < 自身或 -1、无 NaN/Inf、索引 < 顶点数、权重和 ∈ [0.99,1.01]、骨骼索引在范围内);输出格式变体直方图。这一步最先抓出"某批文件是另一种变体"。
- **快照回归** — 在 macOS CI runner 上用 bgfx 离屏渲染确定性场景,PNG 与提交的 golden 带容差比对。
- **三端冒烟** — CI 里 iOS 模拟器 + Android 模拟器构建并启动,断言"到达首帧 + 连续 N 帧无 bgfx / GL / Metal 验证层报错"(打开 `BGFX_DEBUG_*`、Metal API validation、GLES `KHR_debug`)。
### 测试资产集(精选约 15 个)
**由 M0 的 fuzz 元数据 + 人工挑选产出**,写进 `test/assets.list`,后续所有里程碑都跑这一套:
- 刚体静态件(zone 建筑)
- 单材质蒙皮角色(`warrior_novice`
- 多材质 + 带 alpha 的蒙皮角色(时装)
- **多部件装配角色 + 挂点武器**(考验 `.msm` 多 gr2 组装 + `GrannyFindBoneByName` + 挂点矩阵)
- 高骨骼数 / 长动画
- 同一模型的全部 4 级 LOD(考验 LOD 间骨骼绑定一致性)
- 一个用了 `D3DTOP_MODULATE2X``ADDSIGNED` 的多 stage 材质(专验 `texture_stage.sh`
- 特效 / 挂点网格
- fuzz 阶段标出的异常个例(曲线子类型、压缩变体、超大骨骼数等的代表)
---
## 08 · 风险登记册
严重度:**高** 可能否决方案或大幅拖延 · **中** 需专门投入 · **低** 有成熟解法。
| 领域 | 风险 | 影响 / 缓解 | 严重度 |
|---|---|---|---|
| **oracle** | granny2.dll 版本与资产格式不匹配(资产 v6,src 头写 2.11.8 | 拿错误参考校 libgr2,全盘失真。**M0 起手锁定"能正确加载这批资产的 DLL 版本"**,固定进 `oracle/`(见 [§05](#m0-第一件事锁定-oracle-用的-granny-版本))。 | **高** |
| gr2 | **section 全部 Oodle1 压缩**(M0 T1 实测确认,非可选);个别文件可能混 Oodle0 / BitKnit | Oodle0/1 有公开重实现(~200 行 LZ 变体),M0 T2 必做;BitKnit 无公开实现 —— 9166 全样本 0 个,若真遇到 → Windows 侧 Granny SDK 离线转未压缩。 | 中 |
| gr2 | 自描述类型树递归 / 未知类型 / 重定位表 | 写通用类型树遍历器(不假设布局)+ 与 SDK dump 逐字段对拍。 | 中 |
| 动画 | Granny 曲线压缩格式多样(关键帧数组、D3/D4nK 量化、B 样条拟合) | 三层退路:① M0 统计实际用到的子类型,多数只需实现关键帧 + 简单量化;② `granny_curve*.cpp` 当算法参照;③ **子类型超范围就走"oracle 离线烘焙成密集关键帧"(每骨每帧一个 TRS),libgr2 只读烘焙格式,PoC 完全绕开曲线解码,代价是动画数据变大 + 一个烘焙工具**。加了 ③ 后此项可视为"中"。 | **高** → 中(有 ③ 兜底) |
| 动画 | ease-in/out 曲线、loop、局部时钟语义 | 照 `EterGrnLib/Motion*``ModelInstanceMotion.cpp` 的调用序列逐行复刻。 | 中 |
| 蒙皮 | 骨骼绑定顺序 / mesh binding 到骨架的重映射 | 复刻 `GrannyNewMeshBinding` 的按名匹配逻辑;M2 门禁先过骨骼矩阵、再过顶点,逐级隔离。 | 中 |
| 坐标系 | Granny 轴向 / 单位 → 运行时约定的 basis 转换(一个 90° 轴交换或 ×100 缩放目视看不出) | **主抓手是数值锚点**oracle dump 与 libgr2 输出都声明同一坐标约定(手系、单位、根变换是否 apply),骨骼矩阵逐元素对拍即可暴露;复刻 `GrannyConvertSingleObject`;朝向明确的资产做辅助目视。 | **高** |
| 集成 | sokol_app + bgfx 都想拥有 swapchain/context,非 sokol_app 常规用法 | M1 独立子门禁验证交接;退路是桌面 SDL2 + iOS/Android 各写最小原生壳(见 [§03 窗口层](#窗口层sokol_app-与-bgfx-的交接是一处真风险))。 | 中 |
| 验证 | `texture_stage.sh` 是未校验的 D3D 固定功能重实现(MODULATE2X clamp、arg complement、多 stage 串联) | 用一个已知多 stage 材质在 M1/M2 单独和 oracle 对拍,不靠整场景 SSIM 兜。 | 中 |
| 验证 | 数值阈值 ε 拍脑袋,正确代码也可能 fail | 先跑 oracle 两遍测噪声地板,ε 设在地板 + 余量之上(见 [§07 数值层](#数值层))。 | 中 |
| 贴图 | 移动端无 S3TC / DXT 硬件支持(确定项) | PoC 运行时解压 DXT→RGBA8(复用 EterImageLib,显存 ×4);量产改离线转 ASTC / ETC2 或 Basis Universalbgfx `bimg`/`texturec`)。 | **高** |
| 贴图 | NPOT / mipmap 链 / sRGB | GLES3 / Metal 均支持 NPOT;统一线性工作流 + sRGB 纹理视图。 | 低 |
| RHI | 深度范围 0–1 vs −1–1、裁剪空间 Y 翻转、行 / 列主序矩阵 | bgfx 有 `bgfx::getCaps()->homogeneousDepth` / `originBottomLeft` 抹平后端差异;矩阵全程列主序(glm);离屏 RT 用 bgfx 的约定。 | 中 |
| 着色器 | `shaderc` 跨编译到 metal / 300_es / s_5_0 行为差异 | 单一 `.sc` 源;开 bgfx / Metal / GLES 验证层;三端快照对拍。比手写多份 GLSL 更省心。 | 低 |
| 着色器 | `.sc` 草稿缺蒙皮 / fog / 顶点色,且 `.bin` 是废产物 | 从 `.sc` 重编;新写 `vs_pnt_skinned.sc`,补 fog 与 `a_color0``compile_shaders.bat` 换成 CMake custom command。属已知工作量,非未知风险。 | 低 |
| 精度 | 移动 GPU mediump 存不下骨骼矩阵 / 大坐标 | 顶点着色器里骨骼矩阵与位置用 highp;必要时把模型原点归一。 | 中 |
| 平台 | Android GL context 丢失后 GPU 资源需重建(确定项) | 在 sokol_app 挂起 / 恢复事件里按 bgfx 的 `reset` 流程处理;**M3 就做,别拖到最后**。 | **高** |
| 平台 | iOS 全静态链接、无 dlopen、后台丢 Metal drawable | 全静态;Metal 生命周期照 sokol_app 处理。 | 中 |
| 平台 | 9166 个散装资产上真机的方式没定(APK/bundle 体积、iOS 限制) | M3 前定:提前接 eterpack,或只打测试资产集。M3 交付要显式包含这一步。 | 中 |
| 构建 | bgfx 依赖 `bx` / `bimg` / `bgfx` 三仓 + 单一 CMake 出三端 | 用 `bgfx.cmake` 封装,submodule 固定版本;一开始就配好 toolchain + CI 三端并行。 | 中 |
| 授权 | libgr2 部分模块是从泄露 Granny SDK **直接端口**`oodle1.c` = `radlz.c`/`radarith.c`/`arithbit.c` 的解码路径逐行移植,非"看规格重写");其余(文件头 / 类型树 / 骨架)是照 struct 定义写的 | 比 clean-room 弱。对"内部自用、不公开、非商业"可接受;`oodle1.c` 顶部注明来源。**一旦要公开 / 商用**:`oodle1.c` 必须换成真正的 clean-room 实现或第三方 LZgr2 里的 Oodle1 只是个简单 LZ+算术编码,可重写),Granny 运行时逻辑评估 ozz-animation 替换或采购授权。 | 中 |
| 范围 | PoC 蔓延到地形 / 特效 / UI | 严守"骨骼蒙皮 + 三端跑通"边界;地形 / 特效 / Python 明确排除在 M4 之外。 | 中 |
| 范围 | **Python/UI 层是仅次于 gr2 的第二大未知,被本 PoC 排除** | CPython 3 在 iOS(无 JIT / 全静态 / 脚本预编译)跑 Metin2 那 2000+ 个 `.py` + `EterPythonLib` C 扩展 + 依赖 D3D 图元的 `ui.py` 窗口系统 —— **这是 PoC 之后要立刻做的"第二个 PoC"**,不是"已知可行的工作量"。 | **高**(对完整移植) |
---
## 09 · 工作量 · 人力 · 目录
### 工作量
| 工作项 | 单人全职 | 2 人小队 |
|---|---|---|
| **oracle 工具**Windows:抽出 EterGrnLib 单独编、锁 Granny 版本、两层矩阵 + 顶点 dump、确定态改造、自检) | 1–2 周(与 M0 并行,含在关键路径) | 由会 Windows/D3D 的人专责 |
| M0 gr2 解析器 + fuzz + 结构化对拍 + 筛测试资产集 | 1–2 周 | 合计 ≈ 68 周(libgr2 / oracle+验证台 / RHI+壳 三线并行) |
| M1 macOS 静态渲染(含 bgfx↔sokol_app 交接子门禁 24 天) | 23 周 | |
| M2 动画 + CPU 蒙皮(曲线解码是关键路径) | 2–3 周 | |
| M3 iOS + Android 真机 | 12 周 | |
| **到 go/no-go 结论** | **≈ 23 个月** | **≈ 68 周** |
| M4 逼近真实负载(可选) | +2 周 | +1–2 周 |
**人力硬约束**:oracle 那条线需要一个能在 Windows 上搭 D3D9 客户端构建、把 `EterGrnLib` 拆出来单独链的人 —— 这和写 libgr2 / RHI 的技能不同,单人做要来回切换,小队要专人。
关键路径是 **libgr2 曲线解码(M0 → M2+ oracle 工具(M0** 两条并行。
### 建议目录结构
```
xrender-poc/
third_party/
bgfx.cmake/ bx/ bimg/ bgfx/ submodule,含 shaderc / bin2c
sokol/ 仅 sokol_app.h
glm/
reuse/ EterBase/ EterPack/ EterImageLib/ ← 冻结快照 + 本项目修复提交(非引用)
libgr2/
include/ src/ gr2_file.* gr2_types.* gr2_skeleton.*
gr2_mesh.* gr2_anim.* gr2_decompress.*
engine/ rhi.*(包 bgfx:: skinning.* animation.* material.* scene.* camera.*
formats/ msm.* msa.* race.* ← 照 EterGrnLib/RaceManager 复刻
app/
main.c 窗口壳:默认 sokol_app → bgfx::PlatformData → bgfx::init
交接过不了则切 SDL2(桌面)+ 各平台最小原生壳(见 §03)
shaders/ *.sc + texture_stage.sh + varying.def.sc
← 从 ../MobileSource/Cross Platform/shaders.rar/shaders 种子
+ vs_pnt_skinned.sc(新写)
shaders.cmake 可移植编译步骤:shaderc → metal / 300_es / s_5_0 → bin2c
platform/ macos/ ios/ android/ (壳工程 + toolchain
oracle/ Windows-only:锁定版 granny2.dll + EterGrnLib
dump 两层骨骼矩阵 + 蒙皮顶点 + 确定态截图;坐标约定写死
tools/ gr2dump/ (M0 CLI) gr2fuzz/ snapshot_compare/ anim_bake/(曲线退路)
test/ assets.listM0 产出) golden/ oracle_dumps/ noise_floor.json
cmake/ ios.toolchain.cmake android helpers
CMakeLists.txt
```
### 下一步
**M0** 起,两件事并行:
1. **锁定 oracle 的 Granny 版本** —— 确认这批 v6 资产在哪个 `granny2.dll``GrannyGetFileInfo` 完全正常,固定进 `oracle/`
2. **写 `gr2dump`** —— 输出 `warrior_cheongrin*.gr2` 的结构化 dump(骨架 / 网格 / 曲线)+ glTF;结构化 dump 与 oracle 逐字段对拍(这是主验证),glTF 仅供 Blender 目视骨架和 bind pose。
这是单块价值最高、且不依赖任何 RHI / 平台决策的第一块砖。
---
## 10 · 最终验收与结论证据包
"最终成果如何验证"分两层,PoC 与完整移植的验收方式不同。
| | PoC 的最终成果 | 完整移植的最终成果 |
|---|---|---|
| 是什么 | **一份 go/no-go 结论 + 证据包**(不是能玩的客户端) | 能上架的三端 Metin2 客户端 |
| 验证目标 | "骨骼网格这条链在三端成立" 站得住、可复现 | 功能对等 + 三端一致 + 性能 / 稳定性达标 |
### 10.1 PoC 结论证据包
PoC "通过"的判据不是"看着对了",而是**能交给别人独立重跑并核对的一个包**。仓库 `test/` 下产出:
**① 可复现验证套件(CI 一条绿灯 = 全部过)**
- `gr2fuzz` 报告:9166 / 9166 解析成功 + 格式变体直方图 —— 证明"能读全部资产",不是挑了几个好文件。
- `numeric_diff` 报告:测试资产集 × N 帧 × M 顶点,骨骼矩阵 `max|Δ|` 与蒙皮顶点 `‖Δ‖` 的分布(直方图 + p99 + max),全部 < 阈值。
- `snapshot` 对比:确定性场景在 Windows-oracle / macOS / iOS / Android 四组截图 + 两两 SSIM 矩阵。
- `perf` 报告:每台真机 1 / 8 / 20 / 50 角色的帧时间 p50/p99、FPS、显存、RSS、冷启动、gr2 加载耗时,逐项标注是否越线。
- 三端冒烟:`macos / ios-sim / android-sim` 三个 CI job 全过,无 bgfx / Metal / GLES 验证层报错。
**② 门禁汇总表** —— M0–M3 每道门禁一个明确 pass/fail(见 [§06](#06--里程碑与验证门)),最终成果 = 全绿的汇总。
**③ 反证清单(negative evidence** —— 把当初担心会 block 的点逐条列出实测结果。**下表是待填模板,结论由 M0–M3 产出,不是现在已知的事实**:
| 担心点 | 实测结果(TBD) |
|---|---|
| oracle 用的 Granny 版本 + oracle 自检 | 待 M0 |
| libgr2 `InverseWorld4x4` 自洽(`world_bind · invBind ≈ I` | 待 M0 |
| Granny 曲线子类型分布 / 是否需烘焙退路 | 待 M0 fuzz |
| 非单位 scaleshear 用量(影响蒙皮公式 + mediump | 待 M0 fuzz |
| section 压缩类型分布 | 待 M0 fuzz |
| `.msm` / `.msa` 解析对拍源文本 | 待 M2 |
| 坐标系 basis 转换(数值锚点 + Blender tie-break | 待 M1M2 |
| sokol_app + bgfx 交接 | 待 M1 子门禁 |
| `texture_stage.sh` 多 stage 材质对拍 | 待 M1 |
| ε 噪声地板实测值 | 待 M2 |
| Android context loss 恢复(×100 | 待 M3 |
| iOS 全静态 + 后台 / 锁屏 / 来电恢复 | 待 M3 |
| 移动 GPU mediump 精度专项 | 待 M3 |
**④ 一页纸结论** —— go / no-go
- **go** → 附"正式移植还缺什么"清单(地形 / 特效 / UI / Python 的工作量估计)。
- **no-go** → 具体死在哪层 + 备选路径(ozz-animation 替换 / Rust 重写 / 动画烘焙退路)。
### 10.2 完整移植后的验收(**属 PoC 之后另立项目,不在本方案范围**)
以下 8 条是 PoC 通过、决定正式移植后要另写的验收纲要,本方案不覆盖其计划与工作量。PoC 只证了骨骼网格这一条。
1. **资产全覆盖回归** —— 不是 15 个测试资产,而是全部 gr2 / 全部地图 / 全部特效 / 全部 UI 脚本跑一遍,自动截图 + 与 Windows 客户端**逐场景对拍**(把 oracle 从"单帧 dump"扩成"整机录制回放 + 全场景截图")。
2. **功能对等清单** —— 登录 → 选人 → 进游戏 → 战斗 → 交易 → 公会 → 商城…每个 Python phase 在三端都能走通(手动 + 录制回放)。
3. **三端一致性** —— 同一操作序列,三端渲染 + UI 布局 + 逻辑结果一致。
4. **性能预算** —— 目标机型上完整场景(城市、BOSS 战、大量玩家)达到帧率 / 内存 / 发热 / 耗电 / 包体 / 流量预算。
5. **稳定性** —— 三端各跑 N 小时 monkey / soak;崩溃率 < 阈值;context loss、来电、切后台、锁屏、热重启全过。
6. **兼容性矩阵** —— iOS 最低版本 × 机型;Android GPUAdreno / Mali / PowerVR)× API level × 厂商 ROM。
7. **上架前检查** —— iOS 无私有 API、隐私清单、包大小;Android 64 位、target SDK、权限。
8. **回归防线** —— CI 每次改动跑快照回归 + 性能基准,防"某次改动让 Mali 上花屏"这类回归。
### 10.3 贯穿始终的一条原则
**Windows 客户端是唯一真值源。** 任何"三端自己看着对"都不算数,必须能和 Windows 逐帧 / 逐场景对拍。
这套 oracle 基础设施从 M0 就开始建(先只 dump 骨骼 + 顶点 + 单帧),移植阶段扩成整机录制回放 —— 是整个项目最值的一笔投资。
+32
View File
@@ -0,0 +1,32 @@
# reference/ — archived from xrender-poc
These docs come from **`xrender-poc`**, the bgfx-based rendering POC that was
**retired on 2026-08-29** in favour of the Godot route (this repo). See
[`../MIDREVIEW.md`](../MIDREVIEW.md) for the decision.
They are kept because the reverse-engineering knowledge is route-independent and
still authoritative:
| file | what's still useful |
|---|---|
| `steps/M0-gr2-reader.md` | the entire `.gr2` v6/v7 format breakdown that `libgr2` implements — header, sections, Oodle1, self-describing type tree, `granny_transform`, `OldCurveType` B-splines, the "明确不做" table. **`libgr2` is vendored here now** (`../../libgr2`). |
| `steps/00-oracle.md` | how the Wine + MinGW Granny oracle works — the ground truth for `libgr2` numeric verification. Oracle harness vendored at `../../oracle/`. |
| `steps/M1-static-render.md` | material-binding chain (`granny_material``.Maps[].Map``granny_texture.FromFileName`), DDS/DXT decode, the bgfx-era gotchas. The material logic maps 1:1 onto `m2_material.cpp` here. |
| `steps/M2-anim-skinning.md` | CPU LBS formula, `sample_pose` retarget-by-bone-name, the Granny normal-transform (plain 3×3, **no** inverse-transpose) — same math the GDExtension uses. Numeric gates vs Granny (≤6.5e-5). |
| `steps/M3-mobile.md` | iOS/Android notes from the bgfx shells — historical; Godot handles platform now. |
| `steps/M4-realistic-load.md` | eterpack / full-load plan — deferred in both routes. |
| `PLAN.md` | the bgfx-route master plan. §01 (go/no-go criteria, "工程性延期 vs 方案性死路"), §05 (format findings), §07 (visual-diff layering) are still the shared vocabulary. |
| `DEMO-PLAN.md` | bgfx RHI demo design record. Mostly superseded; kept for the shader/material-stage analysis. |
**Relative paths inside these files** (`../libgr2`, `oracle/`, `test/golden/…`,
`engine/…`, `app/…`) refer to the old xrender-poc layout. The mapping:
| old (xrender-poc) | now (mtgodot-poc) |
|---|---|
| `libgr2/` | `libgr2/` (vendored, unchanged) |
| `formats/` | `formats/` (vendored) |
| `oracle/` | `oracle/` (vendored; `oracle.exe` + `granny2_x64.dll` rebuilt locally) |
| `tools/{gr2dump,gr2fuzz,oracle_diff}` | `tools/` (build with `-DMTGODOT_BUILD_TOOLS=ON`) |
| `engine/`, `app/`, `platform/`, `third_party/` | **dropped** — Godot + the GDExtension in `extension/` replace them |
| `test/golden/*.png` (bgfx shots) | `test/bgfx-reference/` (cross-check images) |
| `test/{noise_floor,m2-numeric}.json`, `assets.list` | `test/` (vendored baselines) |
+184
View File
@@ -0,0 +1,184 @@
# 00 · Oracle —— Windows 真值源工具
> 总纲:[`../PLAN.md`](../PLAN.md) §07。本文件是 oracle 这条**关键路径**的详细施工文档。
> oracle 不是一个里程碑,但它和 M0 并行起步,M0/M1/M2 的主验证全部依赖它。
---
## 目标
在 Windows 侧建一个精简 harness,链接**锁定版本**的 `granny2.dll` + 抽出来的 `EterGrnLib`
对"给定 `.gr2` + `.msa` + 时刻 `t`"能稳定导出:
1. 两层骨骼世界矩阵(① 裸 Granny,② 过完 EterGrnLib
2. mesh 0 蒙皮后的顶点坐标
3. 确定态截图(固定相机 / 光 / model clock / 单动作)
这套 dump 是 Windows-only,永远留在 Windows。
## 前置
- 无硬前置,可与 [M0](./M0-gr2-reader.md) 第一天并行。
- 需要一个会 Windows/MSVC + D3D9 的人(技能和写 libgr2 不同,见 PLAN §09 人力硬约束)。
## 两条轨(关键点)
oracle 分成两半,混在一起会误判工期:
| 轨 | 任务 | 依赖 | 谁需要 | 状态 |
|---|---|---|---|---|
| **Oracle-Lite** —— 只用 `granny2.dll` C API | T1 · T3 · T5 · T6 · T7 · T8 | 一个 `granny2.dll` + 几百行 D3D9 | M0 / M1 / M2 的验证全靠它 | **必做,关键路径** |
| **Oracle-Full** —— 加上抽出来的 `EterGrnLib` | T2 · T4 | 从完整 D3D9 客户端里拆 `EterGrnLib` + 依赖 | 只有正式移植的"层②"对拍 | **可延后**demo/PoC 不阻塞 |
> M2 的门禁只对**层①(裸 Granny)**。层② 是给正式移植准备的。所以 **Oracle-FullT2/T4)不在 demo 关键路径**`EterGrnLib` 拆不干净就整体延后。
## 交付物
| 产物 | 位置 | 轨 |
|---|---|---|
| `granny_probe.exe`(读 gr2 → 打 FileInfo | `oracle/` | Lite |
| `oracle` CLI`oracle dump <model.gr2> <anim.gr2> <t> -o out.bin` | `oracle/` | Lite |
| 最小 D3D9 渲染器(骨架+mesh→PNG,不依赖 EterGrnLib | `oracle/render/` | Lite |
| 锁定的 `granny2.dll` + 判定记录 | `oracle/vendor/` + `oracle/GRANNY-VERSION.md` | Lite |
| dump 二进制格式规范(含坐标约定) | `oracle/FORMAT.md` | Lite |
| 噪声地板测量 | `test/noise_floor.json` | Lite |
| 自检脚本(CI Windows runner | `oracle/selfcheck.*` | Lite |
| `oracle_etergrn` 静态库 + 层② dump | `oracle/full/` | Full(延后)|
---
## 构建顺序(依赖图)
```
T1(锁 DLL + granny_probe) ─┬─▶ T3(层① dump) ─┬─▶ T5(蒙皮顶点 dump) ─▶ T8(噪声地板)
│ │ │
│ └─▶ T7(自检, 先用 1e-4) ◀──┘ T8 后收紧
└─▶ T6(确定态截图 · 独立最小 D3D9) ← M1 视觉验证依赖
[延后 / Oracle-Full] T1 ─▶ T2(抽 EterGrnLib)[L] ─▶ T4(层② dump)
```
**推荐推进**:T1 → T3 → T7(早期最强信号)→ T5 → T6(M1 要)→ T8 → 收紧 T7。T2/T4 只在决定做正式移植时启动。
尺寸:**S** ≈ 0.51 天 · **M** ≈ 24 天 · **L** ≈ 1 周+
---
## 任务分解
> 每个 T 的 AC 是可勾选项。`warrior_cheongrin.gr2` 的期望 count 与 [`M0-gr2-reader.md`](./M0-gr2-reader.md) T4 对齐(第一天用 Blender 导入核对后两边一起填实)。
### T1 · 锁定 Granny 版本 + `granny_probe` **[S]**
> **已知**(M0 T2 从解压后的 gr2 里读到):资产由 **Granny Standard Exporter SDK 2.4.0.7** 导出。候选 `granny2.dll` **优先试 2.4.x 线**2.4.0.7 或最接近的 runtime)。9166 个里 11 个是文件格式 v7、其余 v6。
- 先写 `granny_probe.exe`~30 行):`GrannyReadEntireFileFromMemory``GrannyGetFileInfo` → 打印 `Skeletons/Meshes/Materials/Textures/Animations` 的 count、`FromFileName`、每 skeleton 的骨骼数。
- 收集候选 `granny2.dll`**2.4.x 优先**,再 2.6.x / 2.9.x / 2.11.x 兜底),各链一遍 `granny_probe``warrior_cheongrin.gr2` + 3 个 zone 静态件 + 1 个 `action/*.gr2`
- 选返回全部合理的那个,`git`-track 进 `oracle/vendor/granny2.dll`,判定写 `oracle/GRANNY-VERSION.md`(列出每个候选的输出)。
- **AC**(选定 DLL 下):
- [ ] `warrior_cheongrin.gr2``Skeletons==1``Meshes>=1``Animations==0``FromFileName` 是可读路径串、骨骼数 ∈ [20, 120](人形合理范围)—— 与 [M0 T4](./M0-gr2-reader.md) 期望值一致。
- [ ] `action/dance_1.gr2``Animations==1``Duration` ∈ (0, 60] 秒。
- [ ] zone 静态件:`Skeletons==0` 或 1rigid)、`Meshes>=1`
- [ ] 3+ 文件全过,其余候选 DLL 的失败表现记进 `GRANNY-VERSION.md`
### T3 · dump 层①(裸 Granny **[M]**
-`granny2.dll` C API。输入是**两个 gr2**`model.gr2`(骨架 + ModelInstance+ `anim.gr2``Animation`)。
- 链路:`GrannyReadEntireFileFromMemory(model)``GrannyInstantiateModel` → 读 `anim.gr2``Animations[0]``GrannyPlayControlledAnimation(startTime=0, anim, modelInstance)``GrannySetModelClock(modelInstance, t)``GrannySampleModelAnimations(...)``GrannyGetWorldPoseComposite4x4Array(worldPose, boneCount, 0, out4x4)`
- 输出:`boneCount` + 每骨 `float[16]`(行主序,Granny 原生)。**坐标约定写死**进 `FORMAT.md`:手系、单位、根变换是否已 apply;libgr2 侧声明同一约定。
- **AC**
- [ ] 固定 `(model, anim, t)` 连续两次 dump **byte-identical**(确定性)。
- [ ] **正确性锚**t=0 + identity 动画(或不 play 任何动画)时,层① 的每骨世界矩阵 == 该骨 `InverseWorld4x4` 求逆(`max|Δ| < 1e-4`,即 T7 折进来先跑一遍)。
- [ ] 骨骼数、骨骼名顺序与 `granny_probe` / Blender 一致。
### T5 · dump 蒙皮顶点 **[SM]**
- `GrannyNewMeshBinding(mesh, srcSkel, animSkel)` + `GrannyNewMeshDeformer(...)` + `GrannyDeformVertices(deformer, boneMatrixCount, worldPose4x4, vertexCount, srcVerts, dstVerts)`
- 另用 `GrannyCopyMeshVertices(mesh, PNT332Type, rawBuf)` 读原始顶点作对照。
- 输出 mesh 0 的 `vertexCount` + 每顶点 `float[3]`(要的话加法线)。
- **AC**
- [ ] bind poset=0 / identity)下,`GrannyDeformVertices` 的输出 == `GrannyCopyMeshVertices` 的原始顶点,`max‖Δ‖ < 1e-3`T8 后收紧到 noise_floor)。
- [ ] 顶点数与 [M0 T6](./M0-gr2-reader.md) / Blender 一致。
- [ ] 走路动画某帧的顶点与层① 世界矩阵手算 LBS 的结果一致(自洽)。
### T6 · 确定态截图 harness **[M]** —— M1 视觉验证依赖
- **独立最小 D3D9 渲染器**~200300 行,**不依赖 EterGrnLib**):`CreateDevice``CreateRenderTarget` 离屏 → 用 T3 的世界矩阵 + T5 的蒙皮顶点画三角 → `GetRenderTargetData` → 存 PNGPNG-0 / 无压缩)。
- 强制确定态:注入固定 view/proj、单方向光固定、`GrannySetModelClock(t)` 精确、单动作无混合、无 idle sway(本来就不走客户端 `ActorInstance`,所以天然没有)。
- **AC**
- [ ] 同参数两次截图 byte-identical(或 SSIM = 1.0)。
- [ ] **视觉 sanity**:warrior 是人形、直立、面朝已知方向(+Z)、不炸开 —— 和 Blender 同相机渲染目视一致。
- [ ]`t` 能看到姿势变化(动画链路真的接上了)。
### T7 · oracle 自检(信任前必须过) **[S]**
- 加载 gr2,dump t=0 层① 矩阵,对每骨验 `world_bind[i] · InverseWorld4x4[i] ≈ I``InverseWorld4x4` 来自文件)。
- **AC**
- [ ] 首轮:所有骨 `max|M I| < 1e-4`sanity 天花板)。
- [ ] T8 之后:收紧到 `max|M I| < noise_floor.mat`
- [ ] 过不了 = oracle 的坐标 / 读取有 bug**下游全部作废**,回 T3。
### T8 · 噪声地板 **[S]**
-`(model, anim, t)`:用选定 DLL 跑一遍 + 用一个相邻版本 `granny2.dll` 跑一遍(T1 收集的候选之一);再同版本重复 100 次。
-`mat`(矩阵元素 `max|Δ|`)和 `vtx`(顶点 `max‖Δ‖`)到 `test/noise_floor.json`
- **AC**
- [ ] `noise_floor.json` 产出,含 `mat` / `vtx` 两个值。
- [ ] **sanity**`mat < 1e-3``vtx < 1e-3`(模型单位)。若明显更大 → oracle 还有非确定源没关(回 T3/T6),不是"地板高"。
- [ ] M2 的 `ε_mat` / `ε_vtx` = 对应值 × 安全余量(×10)。
---
## 延后任务(Oracle-Full · 层②)
> 只在决定做正式移植时启动。demo / PoC 不需要。
### T2 · 抽 `EterGrnLib` 单独编 **[L]**
-`m2dev-client-src-main/src/``EterGrnLib` + 依赖最小集(`EterBase` 大部分、`EterLib` 数学 / `GrannyLib` 封装、`SphereLib` 视情况)。
- Windows/MSVC CMake 编成 `oracle_etergrn``#ifdef` 掉 D3D 渲染依赖(`ModelInstanceRender.cpp` 等),目标只是"加载 gr2、建 `CGrannyModelInstance`、跑动作混合、拿骨骼矩阵"。
- **AC**[ ] `oracle_etergrn` 链接通过;[ ] `new CGrannyModelInstance` + `SetModel(warrior_cheongrin.gr2)` 不崩。
- **风险**`EterGrnLib` 依赖 `EterLib` 一大坨(`GrpDevice` 等)。拆不干净 → **整体放弃层②**M0/M2 只对层① 本来就够。
### T4 · dump 层②(过 EterGrnLib **[M]** —— 依赖 T2
- `CGrannyModelInstance::Update(t)``GetBoneMatrixPointer()`,拿过完 LOD 骨骼裁剪 + `ActorInstanceBlend` 混合的最终矩阵。
- **AC**:[ ] 单动作无混合时,层② == 层①(noise_floor 内)——这是 sanity[ ] 混合场景(两动作 blend)与客户端一致(正式移植时再细化)。
---
## 门禁(Oracle-Lite = go / no-go
- **T1**:选定 DLL 下 3+ gr2 的 `GetFileInfo` 全过 AC`GRANNY-VERSION.md` 留档。
- **T7 自检**:全骨 `max|M I|` < 首轮 `1e-4`、T8 后 < `noise_floor.mat`。**这是硬门禁,不过则下游作废。**
- **T3 / T5**:对测试资产集(M0 T9c 产出)产出稳定可复现,含正确性锚(t=0 == bind pose / 原始顶点)。
- **T6**:确定态截图两次一致 + 视觉 sanity(人形直立)+ 换 t 有姿势变化。
- **T8**`noise_floor.json` 产出,`mat` / `vtx` 均 < `1e-3`(否则回 T3/T6 关非确定源)。
- Oracle-FullT2/T4**不是门禁**。
## 验证
- **三方 tie-break**:层① dump vs Blender `io_scene_gr2` 在同 `t` 计算的世界矩阵(Blender 侧写个小脚本)。三方不一致时,**先怀疑 oracle**DLL 版本 / 坐标约定 / T6 非确定源),再怀疑 libgr2。
- Blender 插件选型见 [M0 开工前 TODO](./M0-gr2-reader.md#开工前要定的-todo跑起来才能定非文档缺陷)。
## 本步风险(从 PLAN §08 筛)
| 风险 | 缓解 |
|---|---|
| granny2.dll 版本与资产不匹配 | T1 硬性锁定 + 每候选输出留档 |
| `EterGrnLib` 拆不干净(依赖 `EterLib` 渲染层) | T2/T4 是 **Oracle-Full**,本就延后。拆不动 → 整体放弃层②,M0/M2 只对层① 够用 |
| T6 需要一个 D3D9 渲染器 | 独立写 ~200300 行离屏 D3D9**不碰 EterGrnLib**(否则 T6 被 T2 阻塞,而 T6 是 M1 关键路径) |
| 确定态漏了程序化位移 | oracle 不走客户端 `ActorInstance`,天然无 idle sway;T6/T8 两次不一致就说明还有源,逐个查 |
| T7 阈值 / noise_floor 循环 | T7 首轮用 `1e-4` sanity 天花板;T8 测出真实地板后收紧 T7 |
## DoD 清单(Oracle-Lite
- [ ] `granny_probe.exe` + `oracle/vendor/granny2.dll` 锁定 + `GRANNY-VERSION.md`(含候选对比)
- [ ] `oracle dump <model.gr2> <anim.gr2> <t>` 可用;`oracle/FORMAT.md` 写清二进制布局 + 坐标约定
- [ ] T3 正确性锚:t=0 层① == bind pose`< 1e-4`
- [ ] T5 正确性锚:bind pose 蒙皮 == 原始顶点
- [ ] T6 确定态截图两次一致 + 视觉 sanity + 换 t 有变化
- [ ] T7 自检脚本在 Windows CI runner 绿(首轮 `1e-4`T8 后收紧)
- [ ] `test/noise_floor.json` 产出(`mat`/`vtx` < `1e-3`),M2 引用它设 ε
- [ ] 层① 与 Blender 三方对拍在测试资产集上一致
- [ ] Oracle-Full,可空)T2/T4 状态记录:已做 / 延后 / 放弃
+292
View File
@@ -0,0 +1,292 @@
# M0 · gr2 解析器(libgr2 + gr2dump + fuzz
> 总纲:[`../PLAN.md`](../PLAN.md) §04 / §05 / §06。演示切片见 [`../DEMO-PLAN.md`](../DEMO-PLAN.md) D1。
> 本文件是 M0 的详细施工文档。
---
## 目标
自研 `libgr2`(只读子集),证明能脱离 Granny 从 Metin2 真实 `.gr2` 读出骨架 / 蒙皮网格 / 骨骼权重 / 动画轨道,
且结构与 [oracle](./00-oracle.md) 逐字段一致。产出后续所有里程碑用的测试资产集。
## 前置
- 无代码前置。
- **构建可全程无 oracle 推进**T1T6、T7a、T8、T9a/b 的**代码实现**都不需要 oracle。
- **需要 oracle 的只有验收对拍**:门禁·主、T4/T6 的 count 对拍、T7b 的曲线结果对拍。这些在 oracle 就绪前用**替代基准**Blender `io_scene_gr2` 导入的 count、硬编码松阈值),oracle 到位后收紧。
- **需要 T8 的噪声地板**`test/noise_floor.json`,来自 [oracle](./00-oracle.md) T8):T5 自洽检查的**收紧阈值**。未就绪前 T5 用硬编码 `1e-3`
- oracle 那条线([00-oracle](./00-oracle.md))并行,由会 Windows/D3D 的人推。
## 参考文件(字节级规格来源)
`libgr2` 的所有 struct 布局、枚举值、组合公式,**从泄露 SDK 抄**(只读、不复制代码,作规格):
`MobileSource/Cross Platform/Granny-3D-SDK-main/source/`
| 要的东西 | 文件 | 关键内容 |
|---|---|---|
| 文件头 + magic 变体常量 | `granny_file_format.h` / `.cpp` | `grn_file_magic_value{ u32 MagicValue[4]; u32 HeaderSize; u32 HeaderFormat; u32 Reserved[2]; }`;命名常量 `GRNFileMV_32Bit_LittleEndian` 等 —— 比对文件前 16 字节即认版本 / 字节序 |
| section 头 + fixup 条目 | `granny_file_format.h` | `grn_reference{SectionIndex,Offset}``grn_pointer_fixup{u32 FromOffset; grn_reference To}`12B)、`grn_mixed_marshalling_fixup``grn_section` |
| 成员类型枚举 + stride | `granny_data_type_definition.h` / `.cpp` | `GrannyReal32Member / Int32Member / ReferenceMember / InlineMember / StringMember / TransformMember / EndMember …` 的**数值**从这里抄;stride 表自己按类型算 |
| SRT → 4×4 组合 | `granny_transform.h` / `.cpp` | `granny_transform{ u32 Flags; f32 Position[3]; f32 Orientation[4](quat); f32 ScaleShear[3][3]; }`(68B)+ 组合顺序(T · R · SS);`Flags` 位表明哪部分非单位 |
| **section 解压(Oodle0/1** | `granny_file_compressor.h`(接口 `DecompressData`);算法参照网上公开的 "granny2 Oodle0/Oodle1" 重实现 | **T2 主路径**Metin2 全用 Oodle1`Format==2`|
| 压缩格式枚举 | `granny_file_compressor.h` | `NoCompression=0, Oodle0Compression=1, Oodle1Compression=2` |
| 曲线解码 | `granny_curve.cpp` / `granny_curve_fast.cpp` / `granny_compress_curve.cpp` | T7b 的算法参照;先看 `granny_curve.cpp` 的未压缩关键帧路径。曲线类型标签(T7a)看 `granny_curve.h``CurveDataHeader` |
| v6 兼容分支 | `granny_back_compat.cpp` | SDK 是 2.9.12,读 v6 文件走 back-compat;扫一遍有无 v6 特有偏移 |
> SDK 头注明 `granny_29` / 2011 / v2.9.12。容器 structheader / section / fixup / typetree / transform)在 2.62.11 之间稳定,v6/v7 差异主要在 FileInfo 里装什么对象,不在容器。
## 约定
- **M0 全程用文件原始值,不加任何 basis fix / 单位缩放**。T5 自洽检查、与 oracle 对拍、gr2dump 输出,全在 raw 空间。坐标系转换(Granny art-tool basis → 左手 Y-up)是 [M1](./M1-static-render.md) 渲染时的事。
- oracle 侧 dump 也声明同一 raw 约定(见 [00-oracle](./00-oracle.md) T3)。
## 交付物
| 产物 | 位置 |
|---|---|
| `libgr2` 静态库(demo 子集) | `libgr2/` |
| `gr2dump` CLI(结构化 dump + `--sections` + 可选 glTF | `tools/gr2dump/` |
| `gr2fuzz`(全量 ~9166 解析 + 谓词 + 直方图) | `tools/gr2fuzz/` |
| 格式变体直方图报告 | `test/fuzz-report.json` |
| 测试资产集 | `test/assets.list` |
---
## 构建顺序(依赖图)
线性读 T1→T9 会在 T7↔T9 卡住。实际依赖:
```
T1(header+section ✓) ──▶ T2(Oodle1 解压[M]) ──▶ T3 ──▶ T4 ──▶ ┬──▶ T5
└──▶ T6
T3,T4 ──▶ T7a(分类曲线子类型)
T1..T6 + T7a ──▶ T9a(收集崩溃) ──▶ T9b(加固) ──▶ T9c(报告 + assets.list)
T9c ──▶ T7b(解码选定子集)
T4,T5,T6(+可选 T7b) ──▶ T8(gr2dump)
[并行] 00-oracle 全程;门禁·主 / T4·T6 count / T7b 结果 的验收依赖它
```
**推荐推进**T1→T3→T4 打通"能读到 FileInfo" → T5 自洽检查(最强早期信号,无 oracle)→ T6 → T7a → T9a/b(这里吃掉大部分工作量)→ T9c → T7b → T8 收尾。
尺寸标记:**S** ≈ 0.5–1 天 · **M** ≈ 24 天 · **L** ≈ 1 周+
---
## 任务分解
> 每个 T 下的"AC"是可勾选的验收标准(不是一句话)。带 *(oracle)* 的项在 oracle 就绪前用替代基准。
### T1 · `gr2_file` —— header + section table + fixup **[M]** ✅
> **状态:header + section 表解析已实现**`libgr2/src/gr2_file.cpp`),`gr2dump --sections` 跑通全部 9166 个文件、0 崩溃。
> 剩下的是 **section 解压(Oodle1,见 T2**和 **fixup 重定位**(需展开后的数据)。
**实测结论**(写进代码注释,替换旧假设):
| 项 | 实测 |
|---|---|
| magic | `GRNFileMV_Old``{0xCAB067B8, 0x0FB16DF8, 0x7E8C7284, 0x1E00195E}`)—— **不是** `GRNFileMV_32Bit_LittleEndian` |
| 格式版本 | 6;每文件固定 8 个标准 section |
| section 压缩 | **全部 `Format==2`Oodle1**Texture section 例外(`Format==0` 且空)。`HeaderFormat`(=0)不是 section 压缩字段。|
| `total_size` 字段 | == 文件实际大小(9166/9166 |
| BitKnit | 全样本 0 个 |
| section 头 | `SectionArrayOffset` 相对 `grn_file_header` 起点(= magic 结构之后 32B);每 `grn_section` = 11×u32 = 44B |
-`grn_section``gr2_decompress` 展开到 `ExpandedDataSize`T2Oodle1)→ 遍历 `grn_pointer_fixup[]``FromOffset` 处的值改写成 `To`(section+offset) 的进程内指针 → 小端机跳过 `grn_mixed_marshalling_fixup`
- **AC**`warrior_cheongrin.gr2` = 92 770 B`warrior_cheongrin_lod_01.gr2` = 68 012 B):
- [x] magic 识别(`GRNFileMV_Old` → 32-bit LE);`version == 6`
- [x] `total_size` 字段 == 文件实际大小。
- [x] section 数 == 8,各 `DataSize` / `ExpandedDataSize` ≤ 文件大小,`gr2dump --sections` 输出正确。
- [x] 每个非空 section 成功 Oodle1 展开到 `ExpandedDataSize`T2 完成)。
- [x] 所有 `grn_pointer_fixup``From` / `To` 落在合法范围,0 越界(9166 全量)。
- [x] root object 的 `grn_reference` 落在合法范围。
- **注**"fixup 后能从 root 走到 skeleton" 不在 T1 验收 —— 需要 T3/T4,见"T1+T3+T4 集成检查点"。
### T2 · `gr2_decompress` **[M]** —— 已实现并验证
> **状态:done。** `libgr2/src/oodle1.c` —— 从泄露 SDK 的 `granny_oodle1_compression.cpp` + `radlz.c` + `radarith.c` + `arithbit.c` 端口了**解码路径**(自适应算术编码 + LZ)。约 450 行 C。
- `format==0` → memcpy`format==2`Oodle1)→ `gr2_oodle1_decompress()``format==1`Oodle0/ `format==4`(BitKnit,全样本 0 个)→ 留桩报错。
- 3-block 结构:`stop0/1/2` = section 的 `First16Bit` / `First8Bit` / `ExpandedDataSize`32 位 / 16 位 / 8 位 marshalling 区各一块,共享算术流)。
- **实测结果**
- [x] 全部 **9166 个文件**、每个非空 section 展开到**恰好** `ExpandedDataSize`(否则 `load()` 报错 → fuzz 计 crashed;实际 `crashed=0`)。
- [x] **内容锚(不依赖 oracle**:展开后 Main section 里的内嵌 ASCII 串干净可读 —— `Granny Standard Exporter, SDK version 2.4.0.7``D:\Ymir Work\pc\warrior\warrior_cheongrin.DDS`、类型成员名 `ExporterInfo` / `ExporterName` 等。解码错了这些会是乱码。
- [ ] (可选加强)与 granny2.dll 的 `GrannyDecompressData` 逐字节对拍一次(oracle 就绪后)。
**顺带实测发现**(更新其它文档):
| 发现 | 影响 |
|---|---|
| 资产由 **Granny Standard Exporter SDK 2.4.0.7** 导出 | [00-oracle](./00-oracle.md) T1 的候选 `granny2.dll` 应锁定 **2.4.x 线**,不是 2.9 / 2.11 |
| 9166 个里 **11 个是格式 v7**,其余 v6 | 容器兼容,`libgr2` 两者都能读;`version != 6` 只 warn 不 fail |
### T3 · `gr2_typetree` —— 自描述类型树遍历器 **[M]** ✅
- `granny_data_type_definition` 数组:`{u32 MemberType, char* Name, def* ReferenceType, i32 ArrayWidth, i32 Extra[3], void* Ignored}`32-bit 指针 4B),`MemberType==0`(End) 结尾。枚举值 + stride 表从 `granny_data_type_definition.h/.cpp` 抄。
- `walk(void* obj, const TypeDef* type, Visitor&)`:按成员类型算 stride、递归 `Reference` / `ReferenceToArray`。**不硬编码 struct 布局**,上层按**成员名**取字段。
- 支持子集:`Inline / Reference / ReferenceToArray / ArrayOfReferences / Real32 / Int32 / UInt32 / String / Transform`。子集外 → 记日志、跳过、不崩。
- **AC**`warrior_cheongrin.gr2` root object):
- [x] 遍历不崩,无越界读(9166 全量)。
- [ ] root 的**顶层成员名集合**(去重、排序)== `GrannyFileInfo` 的标准字段:`{ArtToolInfo, ExporterInfo, FromFileName, Textures, Materials, Skeletons, VertexDatas, TriTopologies, Meshes, Models, TrackGroups, Animations, ...}`(以 `granny_file_info.h` 的实际 struct 为准)。
- [x] `gr2dump --members` 打印 `(name, MemberType, ArrayWidth)`,与 `granny_file_info.h` 一致。
- [x] 未知成员类型 0 个(全量)。
### T1+T3+T4 集成检查点
- [x] 从 root 跟指针无崩走到 `Skeletons[0].Bones[0].Name``warrior_cheongrin` 读出 "Bip01"。
### T4 · `gr2_fileinfo` **[S]** ✅(期望值待复核)
- 定位 `FileInfo` root,按名暴露 span 视图:`Skeletons[] / VertexDatas[] / TriTopologies[] / Meshes[] / Materials[] / Textures[] / Models[] / TrackGroups[] / Animations[]`。对外只给 POD 视图,不泄露内部指针。
- **AC**`warrior_cheongrin.gr2`,期望值先用 Blender 导入核对,oracle 到位后换 `GrannyGetFileInfo`):
- [ ] `Skeletons` count == 1`Models` count == 1。
- [ ] `Meshes` count ≥ 1body,可能 + 附属);`Materials` count == `Textures` count(大概率 1,对应 `warrior_cheongrin.dds`)。
- [ ] `Animations` count == 0(角色本体不带动画,动画在 `action/*.gr2`)。
- [ ] 各 count *(oracle)*`GrannyGetFileInfo` 一致。
- **确认期望值**:M0 第一天先用 Blender 导入 `warrior_cheongrin.gr2` 记下真实 count,填进本节替换"大概率"。
### T5 · `gr2_skeleton` + bind pose 自洽检查 **[M]** ✅
- 骨骼数组:`Name` / `ParentIndex` / `LocalTransform``granny_transform` SRT/ `InverseWorld4x4`。SRT→4×4 组合顺序照 `granny_transform.cpp`
- **自洽检查(纯本地,不依赖 oracle)**:沿父索引累积 `LocalTransform` 重建 `world_bind[i]`,验 `world_bind[i] · InverseWorld4x4[i] ≈ I`
- 阈值:oracle 的 `noise_floor.json` 就绪前用 `max|Δ| < 1e-3`;就绪后收紧到 `noise_floor × 余量`
- 抓"读矩阵带转置 / 手系错、且同样作用于正向读取"这类 oracle 对拍也发现不了的 bug。
- **AC**
- [ ] `warrior_cheongrin` 全骨 `world_bind · invBind``max|Δ|` < 当前阈值,PASS。
- [ ] 骨骼数、`ParentIndex` 数组、骨骼名列表 *(oracle 或 Blender)* 一致。
- [ ] `LocalTransform.Flags` 分布打印(有多少骨带非单位 orientation / scaleshear)。
### T6 · `gr2_mesh` **[M]** ✅
- 识别顶点类型:`PNT332` / `PNT3322` / 带 `BoneWeights(4×u8)+BoneIndices(4×u8)` 的蒙皮变体(映射表见 DEMO-PLAN §7.3)。
- 实现 `CopyMeshVertices` / `CopyMeshIndices` 的效果:逐顶点拷到统一打包结构。`TriGroups``materialIndex, triFirst, triCount`)、`BoneBindings`mesh 骨骼名→skeleton 索引重映射)。
- 代码可无 oracle 构建;验收对拍见 AC。
- **AC**`warrior_cheongrin.gr2` mesh 0):
- [ ] 顶点类型正确识别(是蒙皮变体,含 weights+indices)。
- [ ] 顶点数 / 索引数 / 三角组数 *(oracle 或 Blender)* 一致。
- [ ] `BoneBindings` 里每个骨骼名都能在 skeleton 里查到(0 个悬空)。
- [ ] 每三角组的 `triFirst + triCount*3 ≤ 索引总数`
### T7a · 曲线子类型分类 **[S]** —— T9 依赖 ✅
- 只做**识别 + 分类**,不解码:遍历 `TrackGroups → TransformTracks`,读出每个 `PositionCurve` / `OrientationCurve` / `ScaleShearCurve` 的曲线类型标签(`CurveDataHeader.Format`)。
- **AC**[ ] `gr2fuzz` 能对任意 `action/*.gr2` 输出"用了哪些曲线子类型 + 每种出现次数";[ ] 未知类型有标签、不崩。
### T7b · 曲线解码(选定子集) **[M]** —— T9c 之后 ✅(精度待 oracle 对拍)
- 依据 `fuzz-report.json` 的曲线子类型直方图,实现**占比最高的 1–2 种**(大概率 `granny_curve.cpp` 的未压缩关键帧 `DaKeyframes*` + 线性插值 / slerp)。
- 其余子类型 → 记入报告,走 [M2](./M2-anim-skinning.md) 的 oracle 烘焙退路。
- **AC**
- [ ] `warrior/action/` 里至少 3 个只用已实现子类型的动画,能读出每骨曲线并在若干采样点求值。
- [ ] track 数 / 动画时长 *(oracle)* 一致。
- [ ] 某采样点的骨骼局部变换 *(oracle 层① @同 t)* `max|Δ| < ε`(ε 同 T5 阈值策略)。
### T8 · `gr2dump` CLI **[S]** ✅(`--gltf` 未实现,非门禁)
- `gr2dump <file.gr2> [--sections] [--gltf out.glb]`
- stdout:骨架树(缩进)、每 mesh 顶点/索引/三角组/顶点布局、每 animation 时长+track数+曲线子类型直方图、bind pose 自洽 PASS/FAIL + max 偏差。`--sections` 打 section 表。
- `--gltf`(**辅助、非门禁、可延后**):写骨架 + 第一个 mesh 的 bind pose+ 若 T7b 实现了,第一个 animation)。**用 `tinygltf` 或手写 JSON+bin —— 不用 `cgltf`(写支持弱)**。仅供 Blender 目视。
- **AC**[ ] `gr2dump warrior_cheongrin.gr2` 输出与上述 T4/T5/T6 的期望值一致;[ ] `--gltf` 产物能被选定的 Blender 插件导入(若已实现)。
### T9a · fuzz 收集崩溃 **[M]** ✅
- 遍历全部 `.gr2``find "$XRENDER_ASSET_ROOT" -name '*.gr2'`;路径含空格 `ymir work`;预期 ~9166 个)。
- 每个文件在子进程 / try 里跑 `libgr2` 全流程,捕获崩溃 / 异常 / 断言,记 `(路径, 阶段, 错误)`
- **AC**:[ ] 跑完全量,产出崩溃清单(首轮预期有几十~几百条);[ ] 崩溃按"阶段(header/section/typetree/skeleton/mesh/curve)× 错误类型"聚类。
### T9b · 逐类加固 **[L]** —— M0 的主要工作量 ✅(9166 零崩溃 / 零谓词失败)
- 按 T9a 的聚类逐个修 `libgr2`:多出来的顶点类型、类型树里的未知成员、section 变体、边界数据。
- 每修一类,重跑 T9a 确认该类清零、无回归。
- **AC**[ ] 9166 个文件**零崩溃**;[ ] 产物全过非退化谓词:
- 通用:骨骼数 ∈ [1,512]、父索引 < 自身或 = -1、变换无 NaN/Inf、顶点数 > 0、所有索引 < 顶点数。
- **仅蒙皮顶点格式**:骨骼索引在范围内;权重和 ∈ [0.99,1.01](3 显式 + 1 隐式的先补齐第 4 个)。rigid mesh 跳过这两条。
### T9c · 报告 + 测试资产集 **[S]** ✅
- 输出 `test/fuzz-report.json`:文件格式版本 / section 压缩类型 / 顶点类型 / **曲线子类型(来自 T7a** / 非单位 scaleshear 用量 的直方图。
- 人工挑 + 报告元数据 → `test/assets.list`(约 15 个,清单见 PLAN §07 测试资产集)。
- **AC**[ ] `fuzz-report.json` 五类直方图齐全;[ ] `assets.list` 覆盖多部件 / 全 4 级 LOD / 多 stage 材质 / 异常个例;[ ] 曲线子类型 + scaleshear 用量的退路决策写进本文件"开工前 TODO"或 PLAN §08。
---
## 门禁(go / no-go
- **主**`libgr2` dump vs 真 Granny —— ✅ **达成**`oracle probe``GrannyGetFileInfo``gr2dump` 逐字段一致;`tools/oracle_diff` 对拍 Granny **2.9.12**`GrannyGetWorldPose4x4Array` + `GrannyDeformVertices`23 用例(13 模型 bind + 8 动画帧,含 v7 / 双 root / 刚体)骨骼矩阵 ≤ 4.6e-5、蒙皮顶点 ≤ 6.5e-5`test/m2-numeric.json`)。oracle 在 macOS + Wine + MinGW 跑(`oracle/RUNBOOK.md`)。
- **自洽**T5 bind pose 自洽 —— ✅ `warrior_cheongrin` `6.1e-5` PASS。全量 2013/2017 < 1e-34 个 `>=1e-1` **经 oracle 证实 libgr2 正确**assassin 对 Granny 3.1e-5),是自洽不变量本身对这几个双-root 资产不成立,非 libgr2 bug。
- **fuzz**T9b 全量 9166 —— ✅ **零崩溃 + 零谓词失败**
- **变体可实现**:✅ 无 BitKnit / 无 Oodle0;曲线全 `OldCurveType`degree ≤ 2,T7b 全覆盖。不做的变体见 [`../../libgr2/README.md`](../../libgr2/README.md)「明确不做」(均 0 出现,坏输入干净报错不崩)。
- **产出**:✅ `test/{fuzz-report,m2-numeric,noise_floor}.json` + `test/assets.list` + `test/oracle_dumps/`
- **noise_floor**:✅ `test/noise_floor.json`。同-DLL 确定性 = 0byte-identical);跨实现地板 = mat 4.6e-5 / vtx 6.5e-5;跨版本一档待另一个 granny2.dll。
**M0 结论:全部门禁达成(含 vs 真 Granny 逐字段对拍)。**
## 验证
- **bootstrap 关系**:M0 自身验证用硬编码引导集(`warrior_*` + 若干 zone);`assets.list` 产出后,M1+ 一律用它。
- **辅助**`--gltf` 拖进 Blender 目视骨架拓扑 + bind pose 网格。glTF 证不了动画曲线解码(表达不了 ease / scale-shear / 常量轨道压缩)。
## 开工前要定的 TODO(跑起来才能定,非文档缺陷)
- **[T4/T5/T6 期望值] Blender 导入核对** —— M0 第一天用选定的 Blender 插件导入 `warrior_cheongrin.gr2`,记下真实的 skeleton / mesh / material / vertex / bone count,替换各 T 里 "大概率" 的占位期望值,写进 `test/README.md`
- **[T7b] 曲线子类型清单** —— 具体做哪 1–2 种,要 T7a + T9c 的 `fuzz-report.json` 才知道。T7b 开工前 `gr2_anim.cpp` 先只做 `granny_curve.cpp` 的未压缩关键帧 + 线性插值。
- **[验证] Blender `io_scene_gr2` 插件** —— 有多个同名插件(SWTOR 版、Metin2 fork),轴向约定各异。M0 第一周:定一个、确认能导入 `warrior_cheongrin.gr2`、记录轴向约定到 `test/README.md`。定不下来 → 三方 tie-break 暂时只靠 oracle,或找第二个开源 gr2 库。
## 本步风险(从 PLAN §08 筛)
| 风险 | 状态 / 缓解 |
|---|---|
| **section 全是 Oodle1**(实测 9166),T2 从 [S] 变 [M] | **已解**`oodle1.c` 端口自泄露 SDK9166/9166 展开 == `ExpandedDataSize` |
| **T9b 加固吃掉大部分工期** | **已解**:9166 零崩溃 + 零谓词失败。实际 T9b 几乎没触发额外加固(类型树通用遍历 + 懒解引用 + 全程边界检查一次到位)|
| section 用 Oodle0 | **不适用**:全样本 0 个 |
| section 用 BitKnit | **不适用**:全样本 0 个 |
| 自描述类型树递归 / 未知类型 | **已解**:T3 通用遍历器不假设布局;全量 0 个未知成员类型 |
| 曲线子类型多样(B 样条拟合等) | **已解**:全部 `OldCurveType`degree ∈ {0,1,2}、dim ∈ {0,3,4,9}。T7b 全覆盖(degree 3 全样本 0 个)|
| 非单位 scaleshear 普遍存在 | **确认普遍**2267 skeleton / 13298 骨):组合公式已用完整 `R3·SS3`;通知 M2/M3 蒙皮走完整仿射 |
| `InverseWorld4x4` 读错但一致 | **已兜住**T5 自洽检查全量跑,2013/2017 < 1e-34 个异常定位到辅助骨子树(见上)|
## DoD 清单
- [x] T1 header + section 表:`gr2dump --sections` 跑通全部 9166 个文件、0 崩溃、`total_size` 全对
- [x] T2 Oodle1 解压:9166/9166 全 section 展开 == `ExpandedDataSize`;内嵌路径串解压后干净可读(内容锚)
- [ ] T2 加强:与 `GrannyDecompressData` 逐字节对拍一次(oracle 就绪后)
- [x] T1 fixuppointer fixup 索引建立(`(section<<32|from)->Ref`),懒解引用,0 越界(9166 全量)
- [x] T3 类型树遍历器:root object 顶层成员名集合 == `granny_file_info` 标准字段(13 个),`gr2dump --members` 打印一致
- [x] T1+T3+T4 集成检查点:从 root 跟指针无崩走到 `Skeletons[0].Bones[0].Name` 读出字符串(`warrior_cheongrin` → "Bip01"
- [x] T4 FileInfo`warrior_cheongrin.gr2` → skeletons=1 / models=1 / animations=0 / materials=4 / textures=2 / meshes=5*期望值待 Blender/oracle 复核*
- [x] T5 骨架 + bind pose 自洽:`warrior_cheongrin` `max|Δ|=6.1e-5` PASS(阈值 1e-3);全量 2017 个多骨 skeleton 中 2013 个 < 1e-34 个异常见下)
- [x] T6 网格:`warrior_cheongrin` 5 mesh 全 `PNT332_Skinned`,顶点/索引/三角组自洽(`triFirst+triCount*3==indices`),bone-binding 0 悬空
- [x] T9a/b9166 个 `.gr2` **零崩溃** + **零谓词失败**(骨骼数/父索引/NaN/索引越界/权重和/bone-index 范围)
- [x] T9c`test/fuzz-report.json`6 类直方图)+ `test/assets.list`18 个)产出
- [x] T7a 曲线子类型统计完成(见下);T7b OldCurve 解码(d0 常量 / d1 线性 / d2 二次 B 样条 + 四元数归一)+ `Animation::sample_local``dance_1` 等 3+ 采样点无非有限数
- [ ] 门禁·主:结构 dump vs oracle 逐字段一致(oracle 就绪后)
- [ ] T4/T5/T6 期望值 Blender/oracle 复核(替换"待复核"占位)
- [ ] T7b 精度:某采样点骨骼局部变换 vs oracle 层① `max|Δ| < ε`oracle 就绪后)
## M0 实现结果(fuzz 全量 9166`test/fuzz-report.json`
| 维度 | 结果 |
|---|---|
| 解析成功 | **9166 / 9166**0 崩溃,0 谓词失败 |
| 格式版本 | v6 × 9155magic `GRNFileMV_Old`)、**v7 × 11**magic `GRNFileMV_32Bit_LittleEndian`,非 Old;含 `shaman_lord` / snow_dungeon zone / `warrior_rabbit1`|
| section 压缩 | 非空 section 全 **Oodle1**(64162 段);每文件 1 个空 sectioncompression=none)。**无 Oodle0,无 BitKnit** |
| 顶点类型 | `PNT332` × 4016rigid)、`PNT3322` × 309rigid 双 UV)、`PNT332_Skinned` × 3118。**无 `PNT3322_Skinned`,无未知类型** |
| 曲线格式 | **全部是 `OldCurveType`**Granny 2.4`{Int32 Degree; RefToArray Knots; RefToArray Controls}`,无压缩变体、无 `curve2`/`CurveData` variant)。子类型 = (degree, dim)<br>pos `d0·dim3`(322k) / `d0·dim0`(2.5k,恒等) / `d2·dim3`(41k)<br>rot `d0·dim0`(77k) / `d0·dim4`(79k,常量四元数) / `d2·dim4`(210k)<br>scale `d0·dim0`(321k) / `d0·dim9`(42k,常量 3×3) / **`d1·dim9`(3k,线性 3×3)**。**无 degree 3** |
| ScaleShear | 2267 个 skeleton 至少 1 骨带非单位 scaleshear,全语料 13298 骨。**普遍存在** → 组合公式必须走完整 `R3·SS3`(已实现,warrior self-check 15 个 ss 骨仍 6.1e-5|
| bind pose 自洽 | 多骨 skeleton`<1e-4` × 1835、`<1e-3` × 178、`>=1e-1` × **4** |
**bind pose 自洽 4 个异常**`>=1e-1`):`assassin.gr2`PC + season1,同)、`warrior_rabbit1_backup.gr2``hair_11_1.gr2`
这 4 个都是**多 root skeleton**(如 assassin`assasin_low` + `Bip01` 两个 root),且 model 的 `InitialPlacement` 非单位。
> **后续更正(oracle 对拍后)**:这不是 libgr2 的 bug。`assassin` bind pose 的 **骨骼世界矩阵 + 蒙皮顶点与真 Granny 2.9.12 逐字段一致(3.1e-5 / 4.6e-5**(见 [00-oracle](./00-oracle.md) / `oracle/RUNBOOK.md`)。
> `world[i]·InverseWorld4x4[i] ≈ I` 这个**自洽不变量本身**对这几个资产不成立 —— 它们的 `InverseWorld4x4` 是相对「不含 InitialPlacement 的参考」烘的,而 `world = local链 · InitialPlacement`。是资产属性,不是读取错误。
> 自洽检查因此是个**比 oracle 对拍弱的信号**:能抓转置 / 手系 bug,但对「InverseWorld 参考系不一致」会误报。`warrior_cheongrin` 等单 root 模型不受影响(IP 为单位,两者等价)。
## 关键格式发现(补 PLAN §05 / DEMO-PLAN §7
1. **`ReferenceToVariantArray` 磁盘布局是 `{def* Type; int32 Count; void* Ptr}`Type 在前)**,不是 `{Count; Type; Ptr}``VertexData.Vertices` 用它。踩过一次。
2. **`transform_track`Granny 2.4= `{String Name; Inline PositionCurve; Inline OrientationCurve; Inline ScaleShearCurve}`** —— 无 `Flags` 成员,且 Position 在 Orientation 之前,与 2.9 SDK 的 `TransformTrackType` 不同。类型树遍历器按文件自带 typedef 解,不硬编码,所以不受影响;但硬编码偏移会错。
3. **root 偏移的两种放法**:多数 Metin2 skeleton 把 model-space 偏移放在 `model.InitialPlacement`root 骨 `LocalTransform` 是单位;少数(如 `warrior_cheongrin`)把偏移烘进 root 骨 local。self-check 必须 `world[root] = Composite(local[root]) · InitialPlacement`
4. **一个文件可有多个 skeleton**(本体 + 武器/挂点,各带自己的 1-骨 skeleton 和 model)。mesh 的 `BoneBindings` 要对每个 skeleton 试解析、取悬空最少的那个。
5. 组合矩阵语义(`granny_transform.cpp BuildCompositeTransform4x4` + `granny_matrix_operations.cpp ColumnMatrixMultiply4x3Impl`):行主序、平移在 elem 12–14、`world[i] = Composite(local[i]) · world[parent]`、上 3×3 = `transpose(R3·SS3)`。self-check 用同一套。
+183
View File
@@ -0,0 +1,183 @@
# M1 · macOS 静态渲染
> 总纲:[`../PLAN.md`](../PLAN.md) §03 / §04 / §06。演示切片见 [`../DEMO-PLAN.md`](../DEMO-PLAN.md) D0D4。
> 本文件是 M1 的详细施工文档。
---
## 目标
`sokol_app`/SDL2 窗口 + bgfx(Metal),把 [M0](./M0-gr2-reader.md) 读出的 gr2 静态网格(绑定姿势)+ DDS 贴图渲染出来,
轨道相机可转。同时验证 **bgfx ↔ 窗口层的交接**PLAN §03 的高风险点)。
## 前置
- [M0](./M0-gr2-reader.md) 完成:`libgr2` 能出骨架 + 网格;`test/assets.list` 产出。
- [oracle](./00-oracle.md) 的 T6(确定态截图)—— M1 视觉对拍要它。
- `EterImageLib``CDXTCImage` 冻结拷进 `reuse/`
## 交付物
| 产物 | 位置 |
|---|---|
| `xrender-demo` 可执行(macOS | `build/app/` |
| `engine/rhi.{h,cpp}` bgfx 薄封装 | `engine/` |
| `engine/scene.{h,cpp}` `camera.{h,cpp}` | `engine/` |
| 着色器:`vs_pnt` / `fs_pnt` / `fs_normalviz` + `texture_stage.sh` | `app/shaders/` |
| 交接方案决议(sokol_app 还是 SDL2 | 记进 `app/README.md` |
---
## 任务分解(按 DEMO-PLAN 的 D0D4 切)
### T0a · toolchain 骨架(对应 D0a
- `CMakeLists.txt``add_subdirectory(third_party/bgfx.cmake)`pin bgfx 到与 `.sc` 草稿相近版本。
- 窗口层**默认用 bgfx 自带 `entry` 或 SDL2**(零风险,bgfx 所有 example 用的)——不是 sokol_app。
- 一个清屏 app`bgfx::setViewClear(0x303030ff)` + `bgfx::setDebug(BGFX_DEBUG_STATS)` + `bgfx::frame()`
- **编一个 dummy `.sc`**`vs_flat.sc` / `fs_flat.sc`),过 `shaderc -p metal --platform osx` + `bin2c`,断言 `*.sc.bin.h` 生成 —— 提前验证 shaderc 链路(否则要到 T2 才触发)。
- hi-dpiresize 事件里 `bgfx::reset(fbWidth, fbHeight)`retina 上用 framebuffer 像素,不是点)。
- **完成判据**:窗口出现纯色背景 + stats 角标;`*.sc.bin.h` 已生成。
### T0b · sokol_app ↔ bgfx 交接实验(对应 D0b,**不阻塞**)
- 另开一个 target,用 `sokol_app.h``sapp_metal_get_layer()`CAMetalLayer*)填 `bgfx::PlatformData::nwh``init.type = Metal`
- 已知障碍:sokol_app 会自建 MTKView + 自己 present,和 bgfx 抢 layerPLAN §03)。
- **成了** → M1 之后统一切 sokol_app(为移动端生命周期铺路)。**没成** → demo 全程用 T0a 的 SDL2/entrysokol_app 留到 [M3](./M3-mobile.md) 再单独攻。
- **完成判据**:二选一有明确结论并记进 `app/README.md`
### T1 · `engine/rhi` —— bgfx 薄封装(≈150 行)
- 接口签名见 DEMO-PLAN §5「engine/rhi」。语义对齐 `StateManager`
- 关键:`init(nativeHandle,w,h)``createVB/IB/Tex2D/Program``setModel/setBones/setTexture/setStageUniforms``submit``beginFrame/endFrame`
- 深度范围 / Y 翻转:用 `bgfx::getCaps()->homogeneousDepth` / `originBottomLeft` 决定 `bx::mtxProj` 参数,别手写。
### T2 · `engine/scene` + `vs_pnt/fs_pnt` —— 白模上屏(对应 D2)
- `scene``libgr2::Mesh` → 打包顶点 → `bgfx::VertexLayout`(映射表见 DEMO-PLAN §7.3)→ `createVB`;索引 → `createIB`;每 `TriGroup` 一个 draw item。
- `vs_pnt.sc` / `fs_pnt.sc`:从 `shaders.rar` 拷改。`vs_pnt``a_color0`fog 先关。
- `camera.{h,cpp}`:轨道相机(鼠标拖 = 绕轨道、滚轮 = 距离)。
- 坐标系:加固定 `basisFix`Granny art-tool basis → 左手 Y-up),用朝向明确的资产手调到"warrior 正着站、面朝 +Z",写进 `scene.cpp` 注释。
- **完成判据**:可转的白模,轮廓 = Blender 里同模型。
### T3 · DXT 解码 + 贴图(对应 D3)
- `reuse/EterImageLib``CDXTCImage``LoadHeaderFromMemory` + `LoadFromMemory` + `Decompress(0, rgba)`
- `warrior_cheongrin.dds` 实测 **512×512 DXT3 5mip** —— 先只传 level 0`BGFX_SAMPLER_MIN_POINT` 关 mip;T3 过后再补全 mip 链。
- `texture_stage.sh``shaders.rar` 原样拷,喂"单 stage MODULATE(TEXTURE, DIFFUSE)"的 uniform(等价 `tex * vertexColor`)。
-`T` 切贴图 / 白模。
- **完成判据**:UV 无错位、无镜像;和 oracle 确定态截图目视一致。
### T4 · 自检工具(对应 D4)
- `fs_normalviz.sc`10 行):`gl_FragColor = vec4(v_normal*0.5+0.5, 1)`。按 `N` 切。
- 线框:`BGFX_STATE_PT_LINES``bgfx::setDebug(BGFX_DEBUG_WIREFRAME)`。按 `W` 切。
- **完成判据**:法线朝外;三角组分段可见且正确。
### T5 · 多 stage 材质专项(PLAN §04 要求)
-`test/assets.list` 挑一个用了 `D3DTOP_MODULATE2X``ADDSIGNED` 的材质,单独渲一帧和 oracle 确定态截图对拍。
- 不能只靠整场景 SSIM 兜 `texture_stage.sh` 的正确性。
- **完成判据**:该材质的着色结果与 oracle 一致(差异分类内)。
---
## 门禁(go / no-go
- **子门禁 1(交接)**bgfx 经窗口层 handle 在 macOS 出画面 + 输入可用。sokol_app 啃不下 → 用 SDL2/entry,本门禁照样算过(方案已决议)。
- **子门禁 2(几何)**:网格拓扑、UV 正确;无背面剔除错误 / 法线翻转。
- **子门禁 3(材质)**:一个多 stage 材质的着色结果与 oracle 对上。
## 验证
- **对比场景两侧都喂同一张预解码 RGBA**(绕开 DXT),截图 diff 只反映几何 / 光照 / texture-stage,不被"软解 vs 硬件 S3TC"的 bit 级差异污染。
- oracle 侧进入确定态(T6:注入相机矩阵、固定光、绑定姿势、无程序化摇摆)。
- 与 oracle 截图像素 diff + SSIM;差异分类见 PLAN §07 视觉层。
- 法线可视化模式自检。
## 本步风险(从 PLAN §08 筛)
| 风险 | 状态 / 缓解 |
|---|---|
| sokol_app + bgfx 抢 context | **规避**M1 用 GLFW`GLFW_NO_API` + `glfwGetCocoaWindow``PlatformData.nwh`),零冲突。sokol_app 留到 M3。 |
| `.sc` 草稿针对某 bgfx 版本,shaderc 编不过 | **已解**`vs_pnt`/`fs_pnt``shaders.rar` 改写对上当前 pin 的 `bgfx_shader.sh``shaderc -p metal` 编过。 |
| `reuse/EterImageLib` 从没链接过 | **规避**DXT1/3/5 自研 `engine/dxt.cpp`~180 行),完全不碰 EterImageLib。 |
| 坐标系 basiswarrior 躺着 / 镜像 / 巨大 | **已解**`basis_fix = rotX(-90°)·scale(0.01)`warrior 正着站、比例约 1.7m。写死在 `scene.cpp`。 |
| DXT 软解 vs 客户端 GPU S3TC 色差 | 对拍时两侧都用软解 RGBA(`engine/dxt.cpp` 产出,喂给 oracle 侧同一张)。 |
| hi-dpi 视口 | `on_fb_size``glfwGetFramebufferSize``rhi::reset`framebuffer 像素)。 |
## DoD 清单
- [x] `xrender-demo` 在 macOS 起窗口(GLFW+ bgfx Metal init 成功(`renderer: Metal`)(T0a
- [x] 交接方案定为 **GLFW**,记进 `app/README.md`T0b)。sokol_app 不用(抢 layer),留到 M3。
- [x] warrior 绑定姿势上屏,正着站、`basis_fix` = rotX(-90°)·scale(0.01)Granny Z-up cm → Y-up m),轨道相机 + 键位 T/N/W/RT2
- [x] DXT1 / DXT3 软解(`engine/dxt.cpp`,绕开 `reuse/EterImageLib`),贴图 + UV 正确、无镜像(`warrior_cheongrin.dds` 512² DXT3、`warrior_face.dds` 256² DXT1)(T3
- [x] `N` 法线可视化(朝外、平滑)/ `W` 线框(拓扑正确)自检(T4
- [ ] 一个多 stage 材质与 oracle 对拍通过(T5)—— **待 oracle**`texture_stage.sh` 单独走 `fs_stage.sc`M1 主路径用固定 MODULATE(TEXTURE,DIFFUSE)
- [ ] 与 oracle 确定态截图 SSIM ≥ 0.98 或差异分类通过 —— **待 oracle**
## M1 实现结果
| 项 | 结果 |
|---|---|
| 依赖 | bgfx.cmake + bx/bimg/bgfx + glm + glfw`third_party/`pin 见 `VERSIONS.md`)。`tools/bootstrap-submodules.sh` 拉。 |
| 构建 | `cmake -B build -DXRENDER_BUILD_DEMO=ON -DXRENDER_BUILD_TOOLS=OFF``cmake --build build --target xrender-demo`。着色器经 bgfx 的 `shaderc` 编成 `metal` `.bin``cmake/xrender-shaders.cmake`),运行时加载。 |
| 无头验证 | `XR_HIDDEN=1`(隐藏窗口)+ `XR_SCREENSHOT=<path>`bgfx `requestScreenShot` → TGA`rhi` 自带 `CallbackI`+ `XR_MODE=normals|wireframe|bindpose` + `XR_FRAMES=n`。CI / 无显示器也能出确定态截图。 |
| 参考截图 | `test/golden/m1-warrior_cheongrin-bindpose-textured.png``-normals.png` |
| 几何 | 5 meshObject16 / face / Object03 / Object09 / body),顶点/索引数与 M0 一致;bind pose = Granny T-pose;朝向、比例正确 |
| 坑 | ① 近期 bgfx 把 `platform.h` 并进 `bgfx.h`;② `bgfx::setUniform` 要在每次 `submit` 前调(frame 头单调一次不可靠)→ `rhi` 缓存 light/stage 每 draw 重设;③ 截图 TGA 带 alpha 通道,shader 输出非 1 的 alpha 会让 PNG 查看器合成成白 → 不透明物体 shader 固定 `gl_FragColor.a = 1`,截图 writer 也强制 alpha 255 |
## 画质改进 pass(档 1 + 档 2)
用户反馈"渲染毛糙"后做的一轮画质提升。基线:无 MSAA / 无 mip / 平光 / 贴图靠文件名瞎猜 / 无 sRGB。
### 档 1 —— 采样与着色正确性(`engine/` + `app/shaders/`
| 项 | 做法 |
|---|---|
| MSAA | `rhi.cpp` `init`/`reset``BGFX_RESET_MSAA_X4`state 加 `BGFX_STATE_MSAA` |
| mip 链 | `engine/dxt.{h,cpp}` 重写:`decode()``dwMipMapCount`(off 28) 循环解每一级;`Image.mips` 存 level0..N。`scene.cpp` 把各级拼成一块传 `rhi::create_tex2d(packed, …, mips)`bgfx `hasMips = mips>1` |
| sRGB 正确性 | `create_tex2d``BGFX_TEXTURE_SRGB`(采样自动 sRGB→linear);`fs_pnt.sc` 线性空间着色,末尾 `pow(col, 1/2.2)` 编回显示空间 |
| 各向异性过滤 | `create_tex2d``BGFX_SAMPLER_{MIN,MAG}_ANISOTROPIC` |
| 半球环境光 | `fs_pnt.sc``mix(u_ambientGround, u_ambientSky, n.y*0.5+0.5)` 代替常数 ambient |
| 3 盏方向光 | `LightDesc``dir[3]`/`color[3]`key/fill/顶光),`.w` = 强度;`u_lightDir/u_lightColor``Vec4,3` uniform |
| 双面光照 | `fs_pnt.sc``abs(dot(n,l))`(薄片/头发/飘带两面都受光,和 Metin2 一致) |
| 法线 renormalize | vs 输出前 + fs `normalize(v_normal)` |
### 档 2 —— 材质绑定(`libgr2` + `engine/`
**这是"毛糙"的最大来源**:原来整个模型套一张 `<gr2 名>.dds``sura_lord` 根本没有 `sura_lord.dds` → 纯黑。
| 项 | 做法 |
|---|---|
| libgr2 解材质 | `gr2_mesh.cpp` `material_texture_name()``granny_material` → 直接 `.Texture.FromFileName`,或递归 `.Maps[].Map`**注意**:本版 Granny 里 `granny_material_map` 的子材质成员名是 `Map` 不是 `Material`)。`gr2_fileinfo.cpp``FileInfo.materials`(顶层表,调试用)。 |
| 每网格贴图表 | `Mesh.material_textures`(与 `granny_mesh.MaterialBindings` 平行);`tri_group.material_index` 索引它 |
| 逐 tri_group 上贴图 | `scene.cpp``SubMesh.ranges``DrawRange{ib,index_count,tex,alpha_cutout}`),一个网格按 tri_group 切多段,**每段切出独立 IB**,各自贴图。贴图按 `FromFileName` 的 basename 在 gr2 同目录里大小写不敏感查找,带缓存。找不到时回落老的 stem 猜测。 |
| bind pose 也要蒙皮 | 很多 Metin2 模型(shaman_lord 等)的 raw 顶点不在 bind 空间,直接上 raw 会整块错位。`Scene::set_bind_pose()` 有骨架时走 `File::bind_pose(0)` 的蒙皮矩阵(= `set_pose`);bounds/相机框选也按 bind 姿势的顶点算。 |
| alpha-test(镂空) | 解贴图时看 mip0 的 alpha 分布:**同时**有 >5% 近 0 且 >20% 近 255(双峰)才判 cutout(头发/飘带/树叶)。只看"低 alpha 比例"会误伤 alpha 平面全 0 的不透明 DXT3(整块被 discard)。`rhi::set_alpha_test` 逐 draw 开,`fs_pnt.sc``texColor.a < ref → discard`。 |
### 排障中发现并修掉的两个真 bug
| bug | 现象 | 根因 / 修复 |
|---|---|---|
| **多材质网格第 2 段起整块飞出视锥** | shaman_lord(1 网格 2 材质组)只渲出头顶一小撮;任何多组网格丢掉第一组之后的内容 | `Scene::draw``rhi::set_model()`(→ `bgfx::setTransform`)每网格只调一次,但 **bgfx 每次 `submit` 消费一次 transform**。第 2 个 draw range 没设 transform → 用单位阵 → 顶点(~120 单位的 skin 空间坐标)画在 basis_fix 之外。**修复**`set_model` 移进 range 循环,逐 submit 重设。 |
| **bind pose 直接上 raw 顶点** | shaman_lord 等模型整体错位、相机框选发飞(模型变成一个远处的点) | raw 顶点不在 bind 空间。**修复**:`set_bind_pose` 走蒙皮矩阵(见上表)。 |
调试加了 `XR_CAM_YAW` / `XR_CAM_PITCH` / `XR_CAM_DIST`(乘子)env 覆盖初始相机,`XR_VERBOSE` 打 bounds / 相机参数,方便无头抽查各角度。
### 结果
- `warrior_cheongrin` / `warrior_novice` / `sura_lord` / `assassin` / `shaman_lord` / `snakeman` 等正/背/动画各角度截图:贴图正确、比例朝向对、边缘平滑、明暗有层次(见 `test/render-samples/`
- **无回归**`gr2fuzz` 9166/9166、`render_fuzz` 9166/91660 crash/empty/nan)、oracle 逐字段对拍 23/23(≤6.5e-5)、iOS 交叉编译通过
### 明确不做(本 POC 范围外,记档)
| 项 | 原因 |
|---|---|
| 多 stage 材质混合(`fs_stage.sc` | Metin2 角色基本是单 stage MODULATE(TEXTURE,DIFFUSE);多 stage 主要用于地形。`texture_stage.sh` 已备,未接主路径 |
| alpha blend(半透明排序) | cutoutalpha-test)已覆盖头发/飘带主要场景;真半透明要 OIT 或按深度排序,收益低 |
| 顶点色 | 语料里角色网格全是 PNT332(无 color 分量) |
| `.msm` 装配(换发型/换肤) | `formats/msm.cpp` 已能解析,装配是模型组合子系统,非渲染画质 |
| 法线贴图 / 切线帧 | PNT332 无切线;Metin2 资产也没有法线贴图 |
| 阴影 / IBL / FXAA / LOD 选择 | 已有 MSAA x4;阴影/IBL 是独立子系统,env map 资产缺失;FXAA 在 MSAA 之上边际收益小 |
| 特效 / 粒子 / 地形 / 水 / SpeedTree | 独立子系统,POC(读+渲+动 `.gr2` 骨骼模型)范围外 |
+135
View File
@@ -0,0 +1,135 @@
# M2 · 骨骼动画 + CPU 蒙皮
> 总纲:[`../PLAN.md`](../PLAN.md) §04 / §06 / §07。演示切片见 [`../DEMO-PLAN.md`](../DEMO-PLAN.md) D5(拉伸)。
> 本文件是 M2 的详细施工文档。
---
## 目标
解析 `.msm` / `.msa`,把 [M0](./M0-gr2-reader.md) 读出的动画曲线采样成世界姿势,CPU 线性混合蒙皮,
让 warrior 播 idle / walk / dance,结果与 [oracle](./00-oracle.md) 的**层①(裸 Granny)**逐帧数值一致。
装配一个多部件角色 + 挂点武器,验 LOD 一致性。
## 前置
- [M1](./M1-static-render.md) 完成:静态网格 + 贴图上屏。
- [M0](./M0-gr2-reader.md) T7`gr2_anim` 子集)+ scaleshear 用量已知。
- [oracle](./00-oracle.md) T3(层① dump+ T5(蒙皮顶点 dump)+ T8(噪声地板 → ε)。
## 交付物
| 产物 | 位置 |
|---|---|
| `engine/animation.{h,cpp}` 曲线采样 + 世界姿势累积 | `engine/` |
| `engine/skinning.{h,cpp}` CPU LBS | `engine/` |
| `formats/msa.cpp`+ 视需要 `msm.cpp` | `formats/` |
| `app/shaders/vs_pnt_skinned.sc` | `app/shaders/` |
| `tools/anim_bake/`(曲线退路,视 M0 结论决定是否要) | `tools/` |
| 数值对拍报告 | `test/m2-numeric.json` |
---
## 任务分解
### T1 · `formats/msa` + `msm` 解析 + 验证门
- `.msa`(文本):引用的动画 `.gr2`、混合参数(blend time / ease)、事件。逻辑照 `RaceManager.cpp` / `EterGrnLib/Util.cpp`
- `.msm`(文本,仅多部件时需要):base model gr2、挂点、材质类型。
- **验证门(PLAN §04 要求)**:dump 解析结果(挂点名、动作列表、混合参数),和源文本逐项目视核对。
- **完成判据**`dance_1.msa` 解析出的动画路径 + blend 参数与文本一致。
### T2 · `engine/animation` —— 曲线采样 + 世界姿势累积
- 按局部时钟 `t` 对每骨 position / orientation / scaleshear 曲线插值 → 局部 `granny_transform` → 沿父链累积 → `world[bone]`
- 对应 `GrannyBuildWorldPose` / `GrannyGetWorldPoseComposite4x4Array`
- ease-in/out 曲线、loop count、raw local clock 语义:照 `EterGrnLib/Motion*` + `ModelInstanceMotion.cpp` 调用序列复刻。
- **非单位 scaleshear**M0 若报告普遍存在,`world[bone]` 要保留完整仿射(4×4 或 4×3),不能退化成刚体。
- **完成判据**`gr2_anim` 子集覆盖的动画能采样出每帧世界矩阵。
### T3 · `engine/skinning` —— CPU LBS
- `skinMatrix[bone] = world[bone] · InverseWorld4x4[bone]`
- 逐顶点:`v' = Σ weight[i] · skinMatrix[boneIndex[i]] · v`4 权重),法线用 `skinMatrix` 的 3×3scaleshear 时要用逆转置)。
- `GrannyMeshIsRigid` 为真的网格不蒙皮,只按挂载骨骼刚体变换 —— 分支照搬。
- **完成判据**bind poseidentity 动画)下 CPU 蒙皮结果 == 原始顶点。
### T4 · `vs_pnt_skinned.sc`GPU 版,D5 演示用)
- `mat4 skin = u_bones[a_indices.x]*a_weight.x + u_bones[a_indices.y]*a_weight.y + ...`
- CPU 端 `rhi::setBones(skinMatrix[], boneCount)` 上传 uniform 数组。
- **注意**M2 的**数值门禁走 CPU 蒙皮**(可 dump 顶点对拍);GPU shader 只是演示。CPU/GPU 一致性放 [M4](./M4-realistic-load.md)。
### T5 · 多部件装配 + 挂点武器
- `.msm` → base gr2 + 额外 gr2(头发 / 时装);各自 mesh binding 到**同一骨架**。
- 武器:`GrannyFindBoneByName("Bip01 R Hand"(或对应名))` → 取该骨世界矩阵 × 武器局部挂点矩阵 → 武器 gr2 的 model 变换。
- **完成判据**:挂点武器的世界变换与 oracle 一致。
### T6 · LOD 一致性
- 加载同模型 LOD 03`warrior_cheongrin_lod_01/02/03.gr2`),确认它们绑**同一骨架**、骨骼索引一致。
- 在某帧切 LOD,测顶点位移(对应骨骼的顶点,切换前后位置差)。
- **完成判据**:切换帧顶点位移 < 阈值(无肉眼可见跳变)。
### T7 · 曲线退路(`tools/anim_bake`,条件性)
- **仅当** M0 fuzz 报告曲线子类型超出"1–2 种可实现"范围时启用。
- `anim_bake`Windows 侧用 oracle 把动画按固定帧率(如 60fps)烘焙成密集关键帧(每骨每帧一个 TRS),存自有格式。
- `libgr2` / `animation` 增加"读烘焙格式"分支,PoC 完全绕开曲线解码。
- **完成判据**:烘焙动画在 M2 数值对拍中照常通过。
---
## 门禁(分级,按序)
0. `.msm` / `.msa` 解析子检查通过(T1)。
1. **骨骼世界矩阵**与 oracle **层①(裸 Granny`GrannyGetWorldPoseComposite4x4Array` 直出)** 逐帧对拍,`max|Δ| < ε_mat`noise_floor × 余量)—— 隔离曲线采样 + 姿势累积,**先过**。
- **M2 只对层①**。层②(`ActorInstanceBlend` + LOD 骨骼裁剪)不在 PoC 范围,留正式移植。
2. **蒙皮顶点坐标**与 oracle 逐帧对拍,`‖Δ‖ < ε_vtx`(在矩阵已对上的前提下)—— 隔离蒙皮 + 顶点格式。
3. 挂点武器世界变换与 oracle 一致(T5)。
4. LOD 0–3 共享骨骼绑定,切换无跳变(T6)。
## 验证
- 数值层对拍:N 帧 × M 顶点、**全部测试资产集**,不是单文件。
- **必须按序**:先骨骼矩阵,再顶点。否则动画错 + 蒙皮错相互抵消、最终顶点却"对",掩盖两个 bug。
- **tie-break**libgr2 与 oracle 分歧又都合理时,用 Blender `io_scene_gr2` 在同 `t` 算的世界矩阵仲裁(也能抓 oracle 自己的 bug)。
- 视觉层:3 个确定姿势(idle 第 0 帧、走路中段、旋转量大的姿势),差异分类见 PLAN §07。
## 本步风险(从 PLAN §08 筛)
| 风险 | 状态 / 缓解 |
|---|---|
| Granny 曲线压缩格式多样 | **消除**M0 实测全 `OldCurveType`degree ≤ 2`gr2_anim` 全覆盖,无烘焙退路依赖 |
| ease/loop/局部时钟语义 | demo 用简单 loop`fmod(t, duration)`);ease / blend / accumulation 是正式移植照 `EterGrnLib/Motion*` 复刻,PoC 不需要 |
| 骨骼绑定顺序 / mesh binding 重映射 | `gr2::sample_pose` 按骨骼名 retarget181 warrior 动画 NaN=0);skin 矩阵按 `mesh.bone_bindings` → skeleton 索引 |
| rigid + deformable mesh 混合 | `engine/skinning.cpp``mesh.rigid` 分支(单骨刚体 vs 4 权重混合)|
| 非单位 scaleshear 让 `world·invBind` 近似失效 | 全程完整仿射 4x4`mul4x3` 保 3x3 + 平移);法线用 skin 3x3scaleshear 严格应逆转置,M2 先近似,记 M4 收紧)|
| ε 拍脑袋 | 待 oracle T8 的 noise_floor × 10 |
| ~~数值门禁未跑~~ | **已跑**macOS + Wine + MinGW 交叉编译的 `oracle.exe`Granny 2.9.12vs libgr2,骨骼矩阵 + 蒙皮顶点全 ≤ 6.5e-5(float 累积误差量级)。`oracle/RUNBOOK.md` / `tools/oracle_diff` |
## DoD 清单
- [x] `.msm`/`.msa` 解析 dump 与源文本核对通过(`formats/textscript` token 树 + `msa`/`msm`
- [x] **骨骼世界矩阵 vs oracle 层① `max|Δ| ≤ 4.6e-5`** —— `oracle/run-diff-suite.sh`**23 用例**warrior_cheongrin/lord ×4 LOD、assassin 双 root、shaman_lord **v7**、redthief2、snakeman、rabbit_backup、ox_01dance_1/attack/run/wait @ 多个 t)。`test/m2-numeric.json`
- [x] **蒙皮顶点 vs oracle `‖Δ‖ ≤ 6.5e-5`**(同上,vs `GrannyDeformVertices`;法线用同 3x3,Granny 也不做逆转置 —— `engine/skinning.cpp`
- [x] `test/noise_floor.json`:同-DLL 确定性 0,跨实现地板 mat 4.6e-5 / vtx 6.5e-5,ε=1e-3
- [x] **`app/render_fuzz` 全量渲染烟测**9166 个 `.gr2` 全过真管线(bgfx + `Scene::build` + DXT 解码 + CPU 蒙皮 + `submit`skinned 4055 / anim-only 5109 / rigid 2)——**0 崩溃 / 0 空场景 / 0 NaN 姿势**`test/render-fuzz.json`)。
- **顺带修的 libgr2 bug**`redthief_general/{back,front}_damage.gr2` 的 finger track 控制点在文件里就是 `NaN``read_old_curve` 现在遇到非有限 knot/control 整条曲线弃(回退 bind),不把 NaN 灌进蒙皮链。
- 抽样截图里「模型扭曲」的都是 **render_fuzz 把某物种的动画 retarget 到别的 rig**(如怪物动画播到 warrior 身上,或 warrior dance 播到怪物身上)—— 按名 retarget 到 bind pose 比例不同的骨架,肢体会拉长。**不是 libgr2/engine bug**(同 rig 的 warrior + dance_1/attack/run/wait 已对拍 Granny 1e-5)。render_fuzz 已改成只在 rig 匹配时才应用动画,其余画 bind pose。
- [~] 挂点武器变换 —— `gr2::sample_pose` 出每骨世界矩阵,武器 = `world[handBone]·mount`;未接进 demo(无 .msm 武器数据),逻辑就绪
- [x] LOD 03 共享骨架:`warrior_cheongrin` lod_01/02/03 与 base 均 75 骨、骨骼名 + ParentIndex 逐项一致 → 切 LOD 骨骼索引不变、无跳变
- [x] `xrender-demo``Space``dance_1`CPU LBS 每帧更新 dynamic VB,姿势连贯(`test/golden/m2-*.png`
- [ ] (条件)曲线退路 `anim_bake` —— **不需要**M0 实测曲线全 `OldCurveType` degree ≤ 2`gr2_anim` 全覆盖
## M2 实现结果
| 项 | 结果 |
|---|---|
| T1 `.msa`/`.msm` | `formats/textscript.cpp` 通用 token 树 + `msa.cpp`/`msm.cpp``dance_1.msa`→duration 28.333334 / accum 0`throw.msa`→1 event(type10, t=0.824)`warrior_w.msm`→base + hair_path + 75 hairs。dump 与源文本逐项一致。 |
| T2 世界姿势 | `gr2::sample_pose(skeleton, animation, t, world, skin)`libgr2,跨文件:model gr2 的 skeleton + anim gr2 的 tracks,按骨骼名 retarget)。`world[i]=Composite(local[i])·(parent<0?InitialPlacement:world[parent])``skin[i]=InverseWorld4x4[i]·world[i]`。bind pose 时 `skin ≈ I`6.1e-5)。 |
| T3 CPU LBS | `engine/skinning.cpp``v'=Σ wᵢ·skin[bᵢ]·v`4 权重归一),rigid mesh 走单骨刚体分支。`skin_bind_pose_residual` 自检 = 0(skin 全单位 → 输出 == 输入)。 |
| T4 GPU 蒙皮 shader | 跳过:M2 数值门禁走 CPU(可 dump 对拍),demo 也用 CPU + dynamic VB。`vs_pnt_skinned.sc` 留到 M4 CPU/GPU 一致性。 |
| 无 oracle 抽检 | 全部 181 个 `pc/warrior/**.gr2`:139 个带动画,各在 t=0/⅓/⅔/1 采样 —— **NaN=0**56 个 track 全名匹配 skeleton(其余部分匹配,未匹配的骨退回 bind,安全)。 |
| demo 用法 | `XR_ANIM=<anim.gr2\|.msa>` `XR_ANIM_T=<0..1\|秒>` + 交互 `Space` 播放/暂停。 |
+128
View File
@@ -0,0 +1,128 @@
# M3 · iOS + Android 真机
> 总纲:[`../PLAN.md`](../PLAN.md) §04 / §06 / §07。
> 本文件是 M3 的详细施工文档。与 [M2](./M2-anim-skinning.md) 尾段并行。
---
## 目标
把 [M2](./M2-anim-skinning.md) 的工程原样交叉编译到 iOS + Android 真机跑基准场景,
证明三端渲染一致、移动端性能达标、生命周期(context loss / 后台)稳。
## 前置
- [M2](./M2-anim-skinning.md) 门禁 1–2 通过(骨骼矩阵 + 蒙皮顶点对拍)。
- [M1](./M1-static-render.md) T0b 的交接结论(决定移动端窗口壳用什么)。
- **资产上真机的方案**(见下 T1)—— 不是"原样交叉编译"就完事。
## 交付物
| 产物 | 位置 |
|---|---|
| iOS `.app`Xcode / CMake iOS toolchain | `platform/ios/` |
| Android APKNDK / Gradle 或纯 CMake + `native_app_glue` | `platform/android/` |
| 三端 CImacOS 原生 + iOS 模拟器 + Android 模拟器) | `.github/` 或等价 |
| 真机性能采集报告 | `test/m3-perf.json` |
| 三端快照互拍报告 | `test/m3-snapshot.json` |
---
## 任务分解
### T1 · 资产上真机方案(PLAN §04 / §08
- 9166 个散装 `.gr2` + `.dds` 不能直接堆进 APK / `.bundle`(体积、iOS 限制)。二选一:
- **A(推荐)**M3 前提前接 `EterPack`(现排 M4),资产走 `.eix/.epk``CMappedFile` 的 mmap / `AAsset_getBuffer` 路径。
- **B(最小)**:只把 `test/assets.list` 里那 ~15 个 + 依赖贴图打进去。
- **完成判据**:真机上 `libgr2` 能加载 warrior + dance_1,路径与桌面散文件结果一致。
### T2 · 平台壳
- **窗口 / 生命周期**:按 M1 T0b 结论——sokol_app(若交接成了)或各平台最小原生壳(iOS `MTKView` + `CADisplayLink`Android `NativeActivity` / `GLSurfaceView`)。
- **iOS**:全静态链接(无 `dlopen`);`.bundle` 打包资产;`Info.plist` / 签名走开发证书(不涉及上架)。
- **Android**NDK `arm64-v8a` + `armeabi-v7a``AAssetManager` 经 JNI 注入(复用 MobileSource `AndroidMain.cpp` 形态)。
- bgfx `init.type`iOS = `Metal`Android = `OpenGLES`(或 `Vulkan`,先 GLES3 稳)。
### T3 · Android context lossPLAN §08 高危,确定项)
- 切后台 → GL context 连同 GPU 资源可能失效。
-`SUSPENDED` / `RESUMED`(或 `onSurfaceDestroyed/Created`)里按 bgfx 的重置流程:`bgfx::reset` + 必要时重建 `sg_*` / bgfx 资源句柄。
- **测试**:切后台 ×100`adb shell input keyevent KEYCODE_HOME` + 回前台脚本循环)。
- **完成判据**:100 次无崩、无黑屏、无资源泄漏(`adb shell dumpsys meminfo` 稳定)。
### T4 · iOS 后台 / drawable 生命周期(PLAN §10.1
- 切后台 / 锁屏 / 来电 → 丢 `CAMetalDrawable`
- `applicationWillResignActive` 暂停渲染循环;`didBecomeActive` 恢复;`nextDrawable` 返回 nil 时跳过该帧不崩。
- **完成判据**:后台 / 锁屏 / 来电各 ×20 恢复正常。
### T5 · mediump 精度专项(PLAN §08 / §10.1
- 移动 GPU 上 `mediump` 存不下 60 骨链的骨骼矩阵 / 大坐标 → 抖动 / 爆顶点。
- 顶点着色器里骨骼矩阵与位置强制 `highp`;必要时把模型原点归一(减去包围盒中心)。
- **测试**:highp 前后对比截图,确认抖动 / 爆顶点消失。
- **完成判据**:highp 版无可见抖动。
### T6 · 性能采集(PLAN §07 性能层)
- **基准硬件写死具体机型**iPhone 11A13+ Pixel 6Mali-G78+ 一台 Adreno 机。
- **两种角色**:① 简化(≈5k 三角、≈60 骨、单 draw call);② **满配**(多部件 + 武器 + 时装 + 挂点特效,1–2 万三角、多 draw call)。每种 1 → 8 → 20 → 50 个。
- 指标:帧时间 p50/p99、FPS、draw call、GPU 显存(Xcode GPU report / Android GPU Inspector / `adb dumpsys meminfo`)、冷启动到首帧、单 `.gr2` 加载耗时、峰值 RSS。
- 写进 `test/m3-perf.json`,逐项标注是否越线。
### T7 · 三端快照互拍 + CI
- 确定性场景在 macOS / iOS / Android 各渲一帧,两两 SSIM。
- 三端之间应比各自与 oracle 更接近(同 `shaderc` 源、同逻辑)。
- CIiOS 模拟器 + Android 模拟器构建并启动,断言"到达首帧 + 连续 N 帧无 bgfx / Metal / GLES 验证层报错"(开 `BGFX_DEBUG_*`、Metal API validation、GLES `KHR_debug`)。
---
## 门禁(go / no-go
- 三端渲染**差异分类通过**(不是"逐像素一致",见 PLAN §07 视觉层:SSIM ≥ 0.98 直接过;0.950.98 进分类;< 0.95 fail)。
- 性能:简化角色 1 个 ≥ 60fps、20 个 ≥ 30fps**满配角色 1 个 ≥ 60fps、8 个 ≥ 30fps**;冷启动 < 3s;基准 RSS < 300MB;单角色加载 < 30ms。
- 无 bgfx / 图形验证层报错。
- **Android 切后台 ×100 恢复正常**;**iOS 后台 / 锁屏 / 来电恢复正常**。
- **mediump 精度专项通过**highp 后无抖动)。
## 验证
- 三端快照互拍矩阵(T7)。
- 真机性能逐项对通过线(T6)。
- 生命周期压力测试脚本可复现(T3 / T4)。
## 本步风险(从 PLAN §08 筛)
| 风险 | 缓解 |
|---|---|
| Android GL context 丢失后 GPU 资源需重建(确定项) | T3 在 suspend/resume 走 bgfx resetM3 就做别拖 |
| iOS 全静态 + 后台丢 drawable | T2/T4 全静态链接 + drawable nil 跳帧 |
| 9166 散装资产上真机方式没定 | T1 二选一,M3 交付显式包含 |
| 移动 GPU mediump 精度不足 | T5 骨骼矩阵 / 位置用 highp |
| `shaderc` 跨编译 metal / 300_es 行为差异 | 单一 `.sc` 源;开验证层;T7 三端快照对拍 |
| 深度范围 / Y 翻转后端差异 | `bgfx::getCaps()->homogeneousDepth` / `originBottomLeft` 抹平 |
| sokol_app 交接在 iOS/Android 也不成 | 用各平台最小原生壳(T2 备选) |
## DoD 清单
- [~] iOS **模拟器**跑起来,播 dance_1CPU 蒙皮 + bgfx Metal,输出与 macOS 同 t **像素级一致**(同 `.bin` 着色器、同 `demo_core`)。`test/golden/m3-ios-*.png`。**真机**(差异分类、后台/锁屏/来电、mediump、perf)待设备。
- [ ] Android 真机 —— **脚手架就绪(`platform/android/`)未构建**:本机无 NDK。
- [ ] `test/m3-perf.json` —— 待真机
- [ ] Android 切后台 ×100 —— 待设备(`android_main.cpp``APP_CMD_TERM/INIT_WINDOW` 分支已写 shutdown/reinit
- [ ] iOS 后台 / 锁屏 / 来电 —— 待设备(`ios_main.mm``applicationWillResignActive`/`DidBecomeActive` 暂停/恢复 CADisplayLink 已写)
- [ ] mediump → highp 专项 —— `vs_pnt_skinned.sc` 已写(骨骼矩阵 + 位置全 `highp`);对比测试待真机 GPU
- [ ] CI 三端 job —— 待
- [ ] **M3 全绿 = PLAN §01 判定"方案可行"** —— iOS 一端已验证(编译 + 运行 + 渲染一致);Android 端 + 真机压测待设备
## M3 实现结果(本轮)
| 项 | 结果 |
|---|---|
| **平台无关核心** | `app/demo_core.{h,cpp}` —— 加载 gr2 + 建场景 + 采样姿势 + 画,无窗口/输入依赖。桌面壳(`app/main.cpp` GLFW+ iOS 壳(`platform/ios/ios_main.mm` UIKit+ Android 壳(`platform/android/android_main.cpp` native_app_glue)都调它。 |
| **iOS 壳(T2** | `ios_main.mm``UIApplicationMain``MetalView``+layerClass = CAMetalLayer`)→ `CADisplayLink``demo::frame`。bgfx `nwh = CAMetalLayer*``init.type = Metal`。生命周期回调(T4 骨架)已接。 |
| **iOS 构建 + 运行** | `cmake -G Xcode -DCMAKE_SYSTEM_NAME=iOS -DCMAKE_OSX_SYSROOT=iphonesimulator`**BUILD SUCCEEDED**`xcrun simctl` 装 + 跑 iPhone 16 Pro 模拟器:libgr2 加载 warrior + dance_1、DXT3/DXT1 解码、bgfx Metal init、CADisplayLink 跑 120 帧无崩、`requestScreenShot` 出图。渲染结果与桌面同 t 一致。 |
| **交叉编译坑** | ① `BGFX_CONFIG_VIDEO` 默认 ON`video_mtl.cpp` 在 iOS 模拟器编不过(`CVMetalTextureCache` 不可用)→ 关掉。② shaderc 跑不了目标平台 → `xrender_compile_shaders(... PREBUILT <dir>)` 用宿主机预编译的 `metal .bin`Metal 字节码 macOS/iOS 通用)。③ iOS 不建 GLFW`third_party/CMakeLists.txt``NOT IOS` 守卫)。 |
| **Android 壳(T2** | `platform/android/``android_main.cpp`native_app_glue + `ANativeWindow` → bgfx GLES nwh + `AAssetManager` 解资产)、`CMakeLists.txt`NDK,链 `xr_democore` + bgfx GLES)、`build.gradle` + `AndroidManifest.xml``NativeActivity`)。**未构建**(本机无 NDK`platform/android/README.md` 记了装 NDK 后的步骤)。 |
| **T5 GPU 蒙皮 shader** | `app/shaders/vs_pnt_skinned.sc``u_bones[64]` 混合,骨骼矩阵 + 位置 + 法线全 `highp`。**未接进 demo**demo 走 CPU 蒙皮 = M2 数值门禁路径),留 M4 CPU/GPU 一致性 + 真机 mediump 对比。 |
+92
View File
@@ -0,0 +1,92 @@
# M4 · 逼近真实负载(可选)
> 总纲:[`../PLAN.md`](../PLAN.md) §06。
> 本文件是 M4 的详细施工文档。**可选** —— M3 全绿已经能给出 go/no-go 结论,M4 只是把结论从"骨骼网格能跑"推到"接近真实负载也能跑"。
---
## 目标
在 M3 已验证的基础上,加 GPU 蒙皮、eterpack 加载、多角色 + 地面 + 简单光照、LOD 切换,
把性能结论从简化场景推到接近真实的负载。
## 前置
- [M3](./M3-mobile.md) 全部门禁通过。
- [M2](./M2-anim-skinning.md) 的 CPU 蒙皮作为 GPU 蒙皮的对拍基准。
## 交付物
| 产物 | 位置 |
|---|---|
| GPU 蒙皮路径(`vs_pnt_skinned` 全量启用 + 骨骼矩阵 texture 上传) | `engine/skinning.cpp` `app/shaders/` |
| eterpack 加载路径接入 | `reuse/EterPack/` + `engine/scene.cpp` |
| 多角色场景(1 / 8 / 20 / 50 + 地面 + 方向光 | `app/` |
| CPU vs GPU 蒙皮对拍报告 | `test/m4-skin-cmp.json` |
| 扩展性能曲线 | `test/m4-perf-curve.json` |
---
## 任务分解
### T1 · GPU 蒙皮
- 骨骼矩阵改走 GPU:小骨架用 `bgfx::setUniform(u_bones, mtx, boneCount)`uniform 数组,上限 ~128);大骨架 / 多实例用**骨骼矩阵 texture**`bgfx::createTexture2D(RGBA32F)` 每帧 `updateTexture2D`VS 里 `texelFetch`)。
- `vs_pnt_skinned.sc` 从 M2 的演示版转正。
- **完成判据(门禁)**:GPU 蒙皮输出顶点与 M2 的 CPU LBS 结果一致(离屏 transform feedback 或渲到 RT 读回对拍),`‖Δ‖ < ε_vtx`
### T2 · eterpack 加载路径
- `reuse/EterPack`(冻结拷入):`CEterPackManager::RegisterPack` + `Get(mappedFile, "d:/ymir work/.../xxx.gr2", &data)` → LZO 解压 + 解密 → 裸字节喂 `libgr2`
- 需要 `assets/` 打包成 `.eix/.epk``m2dev-client-main/assets/PackMaker.exe``pack.py`Windows 侧一次性做)。
- **完成判据(门禁)**:同一 gr2 经 eterpack 路径 vs 散文件路径,`libgr2` 产出的骨架 / 网格 byte-identical。
### T3 · 多角色场景
- N 个 warrior 实例(各自动画时钟错开),一块带贴图的地面(用一个 zone 静态 gr2 或程序化 quad + terrain 贴图),一个方向光。
- 实例化:bgfx `instanceDataBuffer`(每实例 model 矩阵);蒙皮 texture 每实例一段。
- **完成判据**:50 个角色可交互帧率(真机上 ≥ 20fps 作为下限参考,非硬门禁)。
### T4 · LOD 切换(运行时)
- 按相机距离在 LOD 0–3 间切(用 M2 T6 已验的 LOD 一致性)。
- 切换要平滑:同帧不要让顶点跳(M2 已验位移 < 阈值),或加一帧 cross-fade。
- **完成判据**:镜头拉远/拉近,LOD 切换无肉眼可见 pop。
### T5 · 扩展性能曲线
- 简化 + 满配角色,各 1 / 8 / 20 / 50 个,三台基准机各跑一遍。
- 画帧时间 vs 角色数曲线,标出 CPU 蒙皮 vs GPU 蒙皮两条线。
- 写进 `test/m4-perf-curve.json`
---
## 门禁(go / no-go
- **GPU 蒙皮结果与 CPU 一致**`‖Δ‖ < ε_vtx`)。
- **eterpack 路径与散文件结果一致**byte-identical 骨架 / 网格)。
- 50 角色可交互帧率(参考线,非硬门禁)。
- LOD 运行时切换无 pop。
## 验证
- CPU / GPU 蒙皮对拍(T1)。
- eterpack vs 散文件对拍(T2)。
- 扩展性能曲线覆盖 1 / 8 / 20 / 50 角色 × 两种复杂度 × 三台机(T5)。
## 本步风险
| 风险 | 缓解 |
|---|---|
| GPU 蒙皮骨骼矩阵上传带宽(每帧每实例 60 骨 × 64B) | 用骨骼 texture + `texelFetch`,只 update 变化实例;half-float 视精度 |
| eterpack 解密 key / IV(客户端从服务器拿 `RetrieveHybridCryptPackKeys`) | demo 用离线打包的非加密 pack,或把 key 硬编进 `test/`(自用不公开)|
| 实例化 + 蒙皮 texture 在 GLES3 的上限 | GLES3 保证 `texelFetch` + `RGBA32F` sampledAdreno 老驱动测一下 |
| 50 角色 draw call 爆 | 按材质 / 程序批;满配角色本来就多 draw call,接受较低帧率 |
## DoD 清单
- [ ] GPU 蒙皮输出 == CPU LBS`test/m4-skin-cmp.json`
- [ ] eterpack 路径 == 散文件路径(骨架 / 网格 byte-identical
- [ ] 多角色场景(含地面 + 光)可跑,LOD 运行时切换无 pop
- [ ] `test/m4-perf-curve.json`1/8/20/50 × 简化/满配 × 三机,CPU/GPU 两条线
- [ ] 结论并入 PLAN §10.1 反证清单 + 一页纸结论的"正式移植还缺什么"部分
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env bash
# Build the Android APK end to end: native GDExtension .so -> Godot export -> (opt) install.
#
# ./export-android.sh [Debug|Release] [--install]
#
# Prereqs (one-time), see docs/PLATFORMS.md:
# - Android SDK at ~/Library/Android/sdk with an NDK under ndk/<ver>
# (sdkmanager "ndk;27.2.12479018" "platforms;android-35" "build-tools;35.0.0")
# - JDK 17+ (brew install openjdk@21)
# - Godot 4.7.1 export templates installed
# - ./gen-debug-keystore.sh (once)
# - editor_settings-4.7.tres: export/android/{android_sdk_path,java_sdk_path,debug_keystore}
set -euo pipefail
cd "$(dirname "$0")"
CONFIG="${1:-Debug}"
INSTALL=0
[ "${2:-}" = "--install" ] && INSTALL=1
SDK="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-$HOME/Library/Android/sdk}}"
export ANDROID_NDK_ROOT="${ANDROID_NDK_ROOT:-$SDK/ndk/$(ls -1 "$SDK/ndk" 2>/dev/null | sort -V | tail -1)}"
export JAVA_HOME="${JAVA_HOME:-/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home}"
[ -d "$JAVA_HOME" ] || export JAVA_HOME=/opt/homebrew/opt/openjdk/libexec/openjdk.jdk/Contents/Home
GODOT="${GODOT:-godot}"
ADB="${ADB:-$SDK/platform-tools/adb}"
case "$CONFIG" in
Release) MODE="--export-release" ;;
*) MODE="--export-debug" ;;
esac
OUT="build/export/mtgodot-poc$([ "$CONFIG" = Release ] && echo -release).apk"
echo ">> NDK: $ANDROID_NDK_ROOT"
echo ">> JDK: $JAVA_HOME"
# 1. native GDExtension
./build-android.sh "$CONFIG"
# 2. bake the AssetResolver index (PCK can't std::filesystem-scan on device).
# Regenerate whenever assets/ changes. Harmless if assets are missing.
"$GODOT" --headless --path project --script bake_asset_index.gd || \
echo ">> (asset_index bake skipped — no assets on this machine)"
# 3. Godot export
mkdir -p build/export
"$GODOT" --headless --path project "$MODE" "Android" "$(pwd)/$OUT"
echo
echo ">> APK: $OUT (index: $([ -f assets/asset_index.txt ] && echo baked || echo MISSING))"
unzip -l "$OUT" | grep -E "lib/arm64-v8a/.*\.so" || true
# 3. optional install (+ push the asset zip if it exists)
if [ "$INSTALL" = 1 ]; then
"$ADB" devices -l
"$ADB" install -r "$OUT"
if [ -f build/export/assets.zip ]; then
DEST="/sdcard/Android/data/org.internal.mtgodotpoc/files/assets.zip"
echo ">> pushing assets.zip ($(du -h build/export/assets.zip | cut -f1)) -> $DEST"
"$ADB" shell mkdir -p /sdcard/Android/data/org.internal.mtgodotpoc/files/ || true
"$ADB" push build/export/assets.zip "$DEST"
else
echo ">> NOTE: no build/export/assets.zip — run ./pack-assets.sh; the world won't render without it"
fi
"$ADB" shell am start -n org.internal.mtgodotpoc/com.godot.game.GodotApp
echo ">> logcat: $ADB logcat -s godot GodotError Godot"
fi
+129 -23
View File
@@ -1,41 +1,147 @@
# mtgodot GDExtension — M0' skeleton (registers Metin2Model, links libgr2). # mtgodot GDExtension — registers Metin2Model / Metin2AnimPlayer, links libgr2.
# libgr2 is added at the top-level CMakeLists (vendored under ../libgr2), so here
# we just consume the xrender::libgr2 target.
# --- godot-cpp (submodule @ master) --- # --- godot-cpp (submodule pinned at 101ae38034304346a46ea9ea84ae156d3e860496) ---
# master has no per-minor branch; it ships bundled extension_api-4-*.json. # The parent gitlink is the source-of-truth; .gitmodules intentionally has no
# Pin to 4.7 to match the installed editor (4.7.1; patch-level ABI is compatible). # moving branch. The selected bundle targets Godot 4.7's extension API.
set(GODOTCPP_API_VERSION "4.7" CACHE STRING "Target Godot API version" FORCE) set(GODOTCPP_API_VERSION "4.7" CACHE STRING "Target Godot API version" FORCE)
add_subdirectory(godot-cpp) add_subdirectory(godot-cpp)
# --- libgr2 from the sibling xrender-poc repo --- # --- vendored native deps: libsodium / libzstd / miniLZO ---
# M0': plain sibling-directory reference (both repos live under .../mt/). # Built from source (pinned submodules + vendored miniLZO) so the same tree
# For a portable checkout, either git-submodule xrender-poc or vendor libgr2, # compiles on the Android NDK and iOS SDK, which have no Homebrew. Exposes the
# then pass -DXRENDER_POC_DIR=/path/to/xrender-poc. # aliases mt3p::sodium / mt3p::zstd / mt3p::minilzo. See docs/THIRD-PARTY.md.
set(XRENDER_POC_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../../xrender-poc" add_subdirectory(third_party)
CACHE PATH "Path to the xrender-poc repo (provides libgr2)")
if(NOT EXISTS "${XRENDER_POC_DIR}/libgr2/CMakeLists.txt")
message(FATAL_ERROR
"libgr2 not found at '${XRENDER_POC_DIR}/libgr2'. "
"Pass -DXRENDER_POC_DIR=/path/to/xrender-poc")
endif()
add_subdirectory("${XRENDER_POC_DIR}/libgr2" "${CMAKE_CURRENT_BINARY_DIR}/libgr2")
# --- the extension shared library --- # --- mtnet: Metin2 net protocol core (no godot-cpp dep; libsodium only) ---
add_library(mtgodot SHARED add_library(mtnet STATIC
src/net/secure_cipher.cpp
src/net/net_stream.cpp
src/net/entity_store.cpp
src/net/mark_image.cpp
)
target_include_directories(mtnet PUBLIC src/net)
target_link_libraries(mtnet PUBLIC mt3p::sodium mt3p::minilzo)
target_compile_features(mtnet PUBLIC cxx_std_20)
# --- mtpack: m2dev-fork asset pack reader/writer (libsodium + zstd) ---
add_library(mtpack STATIC
src/pack/eterpack.cpp
src/pack/pack_mount.cpp
src/pack/asset_source.cpp
)
target_include_directories(mtpack PUBLIC src/pack)
target_link_libraries(mtpack PUBLIC mt3p::sodium mt3p::zstd xrender::formats)
target_compile_features(mtpack PUBLIC cxx_std_20)
# --- mtproto: item_proto / mob_proto reader (libsodium + LZO) ---
add_library(mtproto STATIC src/proto/proto.cpp)
target_include_directories(mtproto PUBLIC src/proto)
target_link_libraries(mtproto PUBLIC mt3p::sodium mt3p::minilzo)
target_compile_features(mtproto PUBLIC cxx_std_20)
# Host-only CLIs + tests: they run on the build machine, so skip them entirely
# when cross-compiling the extension for a device.
set(MT_HOST_BUILD FALSE)
if(CMAKE_SYSTEM_NAME STREQUAL CMAKE_HOST_SYSTEM_NAME)
set(MT_HOST_BUILD TRUE)
endif()
if(MT_HOST_BUILD)
add_executable(packtool tools/packtool.cpp)
target_link_libraries(packtool PRIVATE mtpack)
# net_probe: connect to an auth server, run the handshake + CG_LOGIN3, report.
add_executable(net_probe tools/net_probe.cpp)
target_link_libraries(net_probe PRIVATE mtnet)
# net_e2e: full auth -> game -> char list -> select -> PHASE_GAME against a
# real server; dumps entities / points / inventory. Live integration check.
add_executable(net_e2e tools/net_e2e.cpp)
target_link_libraries(net_e2e PRIVATE mtnet)
endif()
if(BUILD_TESTING AND MT_HOST_BUILD)
add_executable(net_cipher_test tests/net_cipher_test.cpp)
target_link_libraries(net_cipher_test PRIVATE mtnet)
add_test(NAME net.cipher_roundtrip COMMAND net_cipher_test)
add_executable(net_loopback_test tests/net_loopback_test.cpp)
target_link_libraries(net_loopback_test PRIVATE mtnet)
add_test(NAME net.loopback_flow COMMAND net_loopback_test)
add_executable(net_entity_test tests/net_entity_test.cpp)
target_link_libraries(net_entity_test PRIVATE mtnet)
add_test(NAME net.entity_store COMMAND net_entity_test)
add_executable(net_mark_test tests/net_mark_test.cpp)
target_link_libraries(net_mark_test PRIVATE mtnet mt3p::minilzo)
add_test(NAME net.guild_mark COMMAND net_mark_test)
add_executable(pack_roundtrip_test tests/pack_roundtrip_test.cpp)
target_link_libraries(pack_roundtrip_test PRIVATE mtpack)
add_test(NAME pack.roundtrip COMMAND pack_roundtrip_test)
add_executable(proto_test tests/proto_test.cpp)
target_link_libraries(proto_test PRIVATE mtproto)
add_test(NAME proto.item_mob COMMAND proto_test)
if(DEFINED ENV{M2_ASSETS})
set_tests_properties(proto.item_mob PROPERTIES ENVIRONMENT "M2_ASSETS=$ENV{M2_ASSETS}")
endif()
endif()
# --- the extension library ---
# macOS/Android: SHARED (Godot dlopen()s it). iOS: STATIC — the platform forbids
# loading dynamic libraries, so the archive is linked into the app at export time
# (see docs/PLATFORMS.md for the remaining F1 export/sign steps).
if(CMAKE_SYSTEM_NAME STREQUAL "iOS")
set(MT_LIB_KIND STATIC)
set(MT_PLAT_TAG "ios")
elseif(CMAKE_SYSTEM_NAME STREQUAL "Android")
set(MT_LIB_KIND SHARED)
set(MT_PLAT_TAG "android")
else()
set(MT_LIB_KIND SHARED)
set(MT_PLAT_TAG "macos")
endif()
add_library(mtgodot ${MT_LIB_KIND}
src/register_types.cpp src/register_types.cpp
src/metin2_model.cpp src/metin2_model.cpp
src/metin2_anim.cpp src/metin2_anim.cpp
src/metin2_world.cpp
src/terrain_splat.cpp
src/static_object.cpp
src/tree_placeholder.cpp
src/environment_builder.cpp
src/water_builder.cpp
src/gr2_bridge.cpp src/gr2_bridge.cpp
src/m2_material.cpp src/m2_material.cpp
src/dxt.cpp src/dxt.cpp
src/asset_io.cpp
src/texture_util.cpp
src/net/m2_client.cpp
src/proto/proto_node.cpp
) )
target_compile_features(mtgodot PRIVATE cxx_std_20) target_compile_features(mtgodot PRIVATE cxx_std_20)
target_link_libraries(mtgodot PRIVATE godot::cpp xrender::libgr2) target_link_libraries(mtgodot PRIVATE godot::cpp xrender::libgr2 xrender::formats mtnet mtproto)
# Drop the dylib straight into the Godot project where the .gdextension expects it: # Godot's .gdextension expects, per platform:
# project/bin/libmtgodot.macos.template_debug.dylib (Debug) # project/bin/libmtgodot.macos.template_{debug,release}.dylib
# project/bin/libmtgodot.macos.template_release.dylib (Release) # project/bin/libmtgodot.ios.template_{debug,release}.a
# project/bin/libmtgodot.android.template_{debug,release}.<arch>.so
set(MT_CFG_TAG "$<IF:$<CONFIG:Release>,template_release,template_debug>")
if(CMAKE_SYSTEM_NAME STREQUAL "Android")
# godot_arch_name-style suffix; OnePlus 13 = arm64.
set(MT_ARCH_SUFFIX ".${CMAKE_ANDROID_ARCH_ABI}")
string(REPLACE "arm64-v8a" "arm64" MT_ARCH_SUFFIX "${MT_ARCH_SUFFIX}")
else()
set(MT_ARCH_SUFFIX "")
endif()
set_target_properties(mtgodot PROPERTIES set_target_properties(mtgodot PROPERTIES
PREFIX "lib" PREFIX "lib"
OUTPUT_NAME "mtgodot.macos.$<IF:$<CONFIG:Release>,template_release,template_debug>" OUTPUT_NAME "mtgodot.${MT_PLAT_TAG}.${MT_CFG_TAG}${MT_ARCH_SUFFIX}"
ARCHIVE_OUTPUT_DIRECTORY "${CMAKE_SOURCE_DIR}/project/bin"
LIBRARY_OUTPUT_DIRECTORY "${CMAKE_SOURCE_DIR}/project/bin" LIBRARY_OUTPUT_DIRECTORY "${CMAKE_SOURCE_DIR}/project/bin"
) )
+40
View File
@@ -0,0 +1,40 @@
#include "asset_io.h"
#include <godot_cpp/classes/file_access.hpp>
using namespace godot;
namespace mtgodot {
PackedByteArray read_file(const String &path) {
if (path.is_empty()) {
return PackedByteArray();
}
// FileAccess::get_file_as_bytes handles res:// / user:// / absolute OS paths.
return FileAccess::get_file_as_bytes(path);
}
bool file_exists(const String &path) {
return !path.is_empty() && FileAccess::file_exists(path);
}
Image dds_from_file(const String &path) {
PackedByteArray b = read_file(path);
if (b.is_empty()) {
return Image{};
}
return load_dds(b.ptr(), (size_t)b.size());
}
std::optional<gr2::File> gr2_from_file(const String &path, gr2::LoadError *err) {
PackedByteArray b = read_file(path);
if (b.is_empty()) {
if (err) {
*err = {"open", std::string("cannot read ") + path.utf8().get_data()};
}
return std::nullopt;
}
return gr2::File::load(b.ptr(), (size_t)b.size(), err);
}
} // namespace mtgodot
+33
View File
@@ -0,0 +1,33 @@
// asset_io — the one portable asset-read point.
//
// The standalone libs (libgr2, formats, mtproto, mtpack) keep their raw
// fopen/ifstream `*_path()` entry points for the non-Godot CTests. Every read
// from the *running extension* goes through here instead, so it works for:
// - res:// (loose files in dev, PCK on iOS/Android read-only bundles)
// - user:// (extracted cache)
// - absolute OS paths (dev: AssetRoot points at mtgodot-poc/assets/)
// godot::FileAccess handles all three transparently.
#pragma once
#include <godot_cpp/variant/packed_byte_array.hpp>
#include <godot_cpp/variant/string.hpp>
#include <gr2/gr2.h>
#include <optional>
#include "dxt.h"
namespace mtgodot {
// Whole-file read. Empty PackedByteArray on failure (path missing / unreadable).
godot::PackedByteArray read_file(const godot::String &path);
bool file_exists(const godot::String &path); // FileAccess::file_exists wrapper
// .dds -> RGBA8 level 0 via read_file. !ok() on failure.
Image dds_from_file(const godot::String &path);
// .gr2 parse via read_file. nullopt on read or parse failure.
std::optional<gr2::File> gr2_from_file(const godot::String &path, gr2::LoadError *err = nullptr);
} // namespace mtgodot
+152
View File
@@ -0,0 +1,152 @@
#include "environment_builder.h"
#include <godot_cpp/classes/environment.hpp>
#include <godot_cpp/classes/procedural_sky_material.hpp>
#include <godot_cpp/classes/sky.hpp>
#include <godot_cpp/core/object.hpp>
#include <m2_coord.h>
#include <algorithm>
using namespace godot;
namespace mtgodot {
namespace {
Color rgba(const fmt::Rgba &c) { return Color(c[0], c[1], c[2], c[3]); }
float luma(const fmt::Rgba &c) { return 0.2126f * c[0] + 0.7152f * c[1] + 0.0722f * c[2]; }
} // namespace
EnvNodes apply_environment(const fmt::Environment &env, Node *parent) {
EnvNodes out;
// --- DirectionalLight (Background) ---
out.sun = Object::cast_to<DirectionalLight3D>(parent->get_node_or_null(NodePath("Sun")));
if (!out.sun) {
out.sun = memnew(DirectionalLight3D);
out.sun->set_name("Sun");
parent->add_child(out.sun);
}
// .msenv Direction 是 Metin2 Z-up 向量(光传播方向)。
fmt::m2coord::Vec3 d = fmt::m2coord::direction_to_godot(
env.dir_light.direction[0], env.dir_light.direction[1], env.dir_light.direction[2]);
Vector3 fwd(d.x, d.y, d.z);
if (fwd.length() < 1e-4f)
fwd = Vector3(-0.4f, -0.7f, -0.55f);
fwd.normalize();
// Godot 光沿自身 -Z 照射 -> basis 的 -Z = fwd
Basis b = Basis::looking_at(fwd, Vector3(0, 1, 0));
out.sun->set_transform(Transform3D(b, Vector3(0, 0, 0)));
if (env.dir_light.bg_enable || luma(env.dir_light.bg_diffuse) > 0.01f) {
out.sun->set_color(rgba(env.dir_light.bg_diffuse));
out.sun->set_param(Light3D::PARAM_ENERGY,
std::clamp(0.9f + luma(env.dir_light.bg_diffuse) * 0.4f, 0.7f, 1.6f));
}
out.sun->set_shadow(true);
out.sun->set_param(Light3D::PARAM_SHADOW_NORMAL_BIAS, 2.0f);
out.sun->set_param(Light3D::PARAM_SHADOW_BIAS, 0.06f);
out.sun->set_param(Light3D::PARAM_SHADOW_MAX_DISTANCE, 500.0f);
out.sun->set_param(Light3D::PARAM_SHADOW_SPLIT_1_OFFSET, 0.08f);
out.sun->set_param(Light3D::PARAM_SHADOW_SPLIT_2_OFFSET, 0.22f);
out.sun->set_param(Light3D::PARAM_SHADOW_SPLIT_3_OFFSET, 0.5f);
out.sun->set_shadow_mode(DirectionalLight3D::SHADOW_PARALLEL_4_SPLITS);
// 角色/物体的间接补光:暖色,能量取自 Material.Ambient
out.sun->set_param(Light3D::PARAM_SPECULAR, 0.4f);
// --- WorldEnvironment ---
out.world_env =
Object::cast_to<WorldEnvironment>(parent->get_node_or_null(NodePath("WorldEnv")));
if (!out.world_env) {
out.world_env = memnew(WorldEnvironment);
out.world_env->set_name("WorldEnv");
parent->add_child(out.world_env);
}
Ref<godot::Environment> e = out.world_env->get_environment();
if (e.is_null())
e.instantiate();
// 天空:SkyBox Gradient -> ProceduralSky 的三段色(zenith / horizon / ground
{
Ref<ProceduralSkyMaterial> psm;
psm.instantiate();
const auto &g = env.sky.gradient;
if (g.size() >= 2) {
psm->set_sky_top_color(rgba(g.front()));
psm->set_sky_horizon_color(rgba(g[g.size() / 2]));
psm->set_ground_horizon_color(rgba(g.back()));
Color gb = rgba(g.back());
psm->set_ground_bottom_color(Color(gb.r * 0.6f, gb.g * 0.6f, gb.b * 0.65f));
}
psm->set_sun_angle_max(6.0f);
Ref<Sky> sky;
sky.instantiate();
sky->set_material(psm);
e->set_sky(sky);
e->set_background(godot::Environment::BG_SKY);
}
// 环境光:Material.Ambient 定色 + 亮度(Metin2 用暖色环境光提亮阴影面)
e->set_ambient_source(godot::Environment::AMBIENT_SOURCE_COLOR);
Color amb = rgba(env.material.ambient);
e->set_ambient_light_color(amb);
e->set_ambient_light_energy(std::clamp(0.5f + luma(env.material.ambient) * 0.4f, 0.35f, 0.95f));
e->set_ambient_light_sky_contribution(0.35f);
// Emissive 当作全局轻微自发光提亮(避免死黑)
e->set_bg_energy_multiplier(1.0f);
// 雾:优先用 .msenv 的 NearDistance/FarDistancecmA1 = 5000/20000 -> 50/200m
// 太近,客户端 D3DFOG 实际按更大的世界尺度;乘一个系数放到远景轻霭区)。没给
// 距离就退回 foglevel 启发式。参考端是线性远景雾 + 天空同色,不是浓雾。
if (env.fog.enable) {
e->set_fog_enabled(true);
e->set_fog_light_color(rgba(env.fog.color));
e->set_fog_mode(godot::Environment::FOG_MODE_DEPTH);
float begin_m, end_m;
if (env.fog.near_distance > 1.0f && env.fog.far_distance > env.fog.near_distance) {
begin_m = env.fog.near_distance * (float)fmt::m2coord::CM_TO_M;
end_m = env.fog.far_distance * (float)fmt::m2coord::CM_TO_M;
// 客户端摄距比我们远:把近雾往后推一截,别糊住中景
begin_m = std::max(begin_m, 120.0f);
end_m = std::max(end_m, begin_m + 400.0f);
} else {
float fl = env.fog.fog_level > 0 ? float(env.fog.fog_level) : 4.0f;
begin_m = std::clamp((11.0f - fl) * 45.0f, 60.0f, 500.0f);
end_m = begin_m + 700.0f;
}
e->set_fog_depth_begin(begin_m);
e->set_fog_depth_end(end_m);
e->set_fog_depth_curve(0.5f);
e->set_fog_density(0.0f);
e->set_fog_sky_affect(0.0f); // 天空自己是渐变,不要被雾再洗一层
e->set_fog_sun_scatter(0.05f);
} else {
e->set_fog_enabled(false);
}
// 色调:参考端是 DX9 定功能、LDR、无 tonemap/HDR。用 LINEAR + 曝光 1 最贴近,
// Filmic 会抬黑、降饱和 -> 画面发灰。轻微加饱和/对比补回胶片感。
e->set_tonemapper(godot::Environment::TONE_MAPPER_LINEAR);
e->set_tonemap_exposure(1.0f);
e->set_adjustment_enabled(true);
e->set_adjustment_saturation(1.12f);
e->set_adjustment_contrast(1.05f);
e->set_adjustment_brightness(1.0f);
// glow:参考端没有 bloom。留极轻的,只有真过曝才溢。
e->set_glow_enabled(true);
e->set_glow_intensity(0.06f);
e->set_glow_strength(0.7f);
e->set_glow_bloom(0.0f);
e->set_glow_hdr_bleed_threshold(1.6f);
// SSAO:参考端把接触阴影烘进地形阴影贴图。这里用一点实时 SSAO 代替。
e->set_ssao_enabled(true);
e->set_ssao_radius(1.2f);
e->set_ssao_intensity(0.9f);
out.world_env->set_environment(e);
return out;
}
} // namespace mtgodot
+21
View File
@@ -0,0 +1,21 @@
#pragma once
#include <godot_cpp/classes/directional_light3d.hpp>
#include <godot_cpp/classes/world_environment.hpp>
#include <environment.h>
// W5 —— .msenv(已由 formats/environment 解析)-> Godot 光照 / 天空 / 雾 / 色调。
// SHINSOO §9-W5。DirectionalLight.Background 驱动场景主光;Character 光留待角色材质
// uniform(§9-W5 note)。云 / lens flare 留 R2。
namespace mtgodot {
struct EnvNodes {
godot::DirectionalLight3D *sun = nullptr;
godot::WorldEnvironment *world_env = nullptr;
};
// 在 parent 下建 / 配 DirectionalLight3D + WorldEnvironment。已存在则复用。
EnvNodes apply_environment(const fmt::Environment &env, godot::Node *parent);
} // namespace mtgodot
+77 -15
View File
@@ -18,6 +18,19 @@ using namespace godot;
namespace mtgodot { namespace mtgodot {
gr2::Mat4 mul4x3(const gr2::Mat4 &A, const gr2::Mat4 &B) {
gr2::Mat4 R{};
for (int i = 0; i < 3; ++i) {
for (int k = 0; k < 3; ++k)
R[i * 4 + k] = A[i * 4 + 0] * B[0 * 4 + k] + A[i * 4 + 1] * B[1 * 4 + k] +
A[i * 4 + 2] * B[2 * 4 + k];
}
for (int k = 0; k < 3; ++k)
R[12 + k] = A[12] * B[k] + A[13] * B[4 + k] + A[14] * B[8 + k] + B[12 + k];
R[15] = 1.0f;
return R;
}
Transform3D gr2_to_godot(const gr2::Mat4 &m) { Transform3D gr2_to_godot(const gr2::Mat4 &m) {
// gr2 row-major, row-vector: basis columns are (m0,m1,m2),(m4,m5,m6),(m8,m9,m10); // gr2 row-major, row-vector: basis columns are (m0,m1,m2),(m4,m5,m6),(m8,m9,m10);
// translation is the 4th row (m12,m13,m14). // translation is the 4th row (m12,m13,m14).
@@ -71,18 +84,59 @@ Ref<Skin> build_skin(const gr2::Skeleton &sk) {
return skin; return skin;
} }
Ref<ArrayMesh> build_mesh(const gr2::FileInfo &fi, bool flip_winding, AABB &out_bounds) { std::vector<RenderPart> build_parts(const gr2::FileInfo &fi) {
std::vector<RenderPart> parts;
for (int mi = 0; mi < (int)fi.meshes.size(); ++mi) {
const gr2::Mesh &m = fi.meshes[mi];
if (m.vertices.empty() || m.indices.empty()) {
continue;
}
const uint32_t total = (uint32_t)m.indices.size();
if (m.tri_groups.size() <= 1) {
RenderPart p;
p.mesh = mi;
p.mat_index = m.tri_groups.empty() ? -1 : m.tri_groups[0].material_index;
p.idx_first = 0;
p.idx_count = total;
parts.push_back(p);
continue;
}
for (int g = 0; g < (int)m.tri_groups.size(); ++g) {
const gr2::TriGroup &tg = m.tri_groups[g];
if (tg.tri_count <= 0) {
continue;
}
uint32_t first = (uint32_t)(tg.tri_first < 0 ? 0 : tg.tri_first) * 3u;
uint32_t count = (uint32_t)tg.tri_count * 3u;
if (first >= total) {
continue;
}
if (first + count > total) {
count = total - first;
}
RenderPart p;
p.mesh = mi;
p.group = g;
p.mat_index = tg.material_index;
p.idx_first = first;
p.idx_count = count;
parts.push_back(p);
}
}
return parts;
}
Ref<ArrayMesh> build_mesh(const gr2::FileInfo &fi, const std::vector<RenderPart> &parts,
bool flip_winding, AABB &out_bounds) {
Ref<ArrayMesh> am; Ref<ArrayMesh> am;
am.instantiate(); am.instantiate();
am->set_name("ArrayMesh"); am->set_name("ArrayMesh");
bool have_bounds = false; bool have_bounds = false;
for (size_t mi = 0; mi < fi.meshes.size(); ++mi) { // vertex arrays are per gr2 mesh; multiple parts of one mesh reuse them.
const gr2::Mesh &m = fi.meshes[mi]; for (const RenderPart &part : parts) {
if (m.vertices.empty() || m.indices.empty()) { const gr2::Mesh &m = fi.meshes[part.mesh];
continue;
}
const int vcount = (int)m.vertices.size(); const int vcount = (int)m.vertices.size();
PackedVector3Array pos; PackedVector3Array pos;
@@ -144,18 +198,23 @@ Ref<ArrayMesh> build_mesh(const gr2::FileInfo &fi, bool flip_winding, AABB &out_
} }
} }
// indices: this part's sub-range of mesh.indices only
const uint32_t ib = part.idx_first;
const uint32_t ic = (part.idx_count && part.idx_first + part.idx_count <= m.indices.size())
? part.idx_count
: (uint32_t)m.indices.size() - ib;
PackedInt32Array idx; PackedInt32Array idx;
idx.resize((int)m.indices.size()); idx.resize((int)ic);
int32_t *idx_w = idx.ptrw(); int32_t *idx_w = idx.ptrw();
if (flip_winding) { if (flip_winding) {
for (size_t t = 0; t + 2 < m.indices.size(); t += 3) { for (uint32_t t = 0; t + 2 < ic; t += 3) {
idx_w[t + 0] = (int)m.indices[t + 0]; idx_w[t + 0] = (int)m.indices[ib + t + 0];
idx_w[t + 1] = (int)m.indices[t + 2]; idx_w[t + 1] = (int)m.indices[ib + t + 2];
idx_w[t + 2] = (int)m.indices[t + 1]; idx_w[t + 2] = (int)m.indices[ib + t + 1];
} }
} else { } else {
for (size_t t = 0; t < m.indices.size(); ++t) { for (uint32_t t = 0; t < ic; ++t) {
idx_w[t] = (int)m.indices[t]; idx_w[t] = (int)m.indices[ib + t];
} }
} }
@@ -169,8 +228,11 @@ Ref<ArrayMesh> build_mesh(const gr2::FileInfo &fi, bool flip_winding, AABB &out_
arrays[Mesh::ARRAY_INDEX] = idx; arrays[Mesh::ARRAY_INDEX] = idx;
am->add_surface_from_arrays(Mesh::PRIMITIVE_TRIANGLES, arrays); am->add_surface_from_arrays(Mesh::PRIMITIVE_TRIANGLES, arrays);
am->surface_set_name(am->get_surface_count() - 1, String nm = m.name.empty() ? String("surf_") + itos(part.mesh) : String(m.name.c_str());
m.name.empty() ? String("surf_") + itos((int)mi) : String(m.name.c_str())); if (part.group >= 0) {
nm += String("#") + itos(part.group);
}
am->surface_set_name(am->get_surface_count() - 1, nm);
} }
if (!have_bounds) { if (!have_bounds) {
+21 -2
View File
@@ -14,6 +14,9 @@
#include <gr2/gr2.h> #include <gr2/gr2.h>
#include <cstdint>
#include <vector>
namespace godot { namespace godot {
class Skeleton3D; class Skeleton3D;
class Skin; class Skin;
@@ -25,6 +28,10 @@ namespace mtgodot {
// 4x4 transpose: gr2 row-major/row-vector -> Godot Transform3D. // 4x4 transpose: gr2 row-major/row-vector -> Godot Transform3D.
godot::Transform3D gr2_to_godot(const gr2::Mat4 &m); godot::Transform3D gr2_to_godot(const gr2::Mat4 &m);
// gr2 affine compose (row-vector: result applies A then B), same as libgr2's
// internal mul4x3. R = A · B with the 4th row treated as translation.
gr2::Mat4 mul4x3(const gr2::Mat4 &A, const gr2::Mat4 &B);
// Z-up cm -> Y-up m (+ optional Z flip for LH->RH content). // Z-up cm -> Y-up m (+ optional Z flip for LH->RH content).
godot::Transform3D make_conv(float unit_scale, bool flip_z); godot::Transform3D make_conv(float unit_scale, bool flip_z);
@@ -32,15 +39,27 @@ godot::Transform3D make_conv(float unit_scale, bool flip_z);
// folded into root bones). Returns nullptr if the skeleton has no bones. // folded into root bones). Returns nullptr if the skeleton has no bones.
godot::Skeleton3D *build_skeleton(const gr2::Skeleton &sk); godot::Skeleton3D *build_skeleton(const gr2::Skeleton &sk);
// One render part = one Godot surface. A gr2 mesh with N>1 tri_groups splits into
// N parts (each its own material); a mesh with 0/1 groups is one whole-mesh part.
struct RenderPart {
int mesh = -1; // gr2::FileInfo::meshes index
int group = -1; // gr2::Mesh::tri_groups index, or -1 = whole mesh
int mat_index = -1; // tri_groups[group].material_index (mesh-local), or -1
uint32_t idx_first = 0; // start into mesh.indices (= tri_first * 3)
uint32_t idx_count = 0; // length into mesh.indices (= tri_count * 3)
};
std::vector<RenderPart> build_parts(const gr2::FileInfo &fi);
// Skin whose bind list is parallel to the skeleton bones: // Skin whose bind list is parallel to the skeleton bones:
// bind i -> bone i, pose = gr2_to_godot(bone[i].inverse_world) // bind i -> bone i, pose = gr2_to_godot(bone[i].inverse_world)
godot::Ref<godot::Skin> build_skin(const gr2::Skeleton &sk); godot::Ref<godot::Skin> build_skin(const gr2::Skeleton &sk);
// One ArrayMesh with a surface per gr2 Mesh that has geometry. // One ArrayMesh with a surface per RenderPart (see build_parts).
// ARRAY_BONES values are skeleton bone indices (mesh slot -> bone via // ARRAY_BONES values are skeleton bone indices (mesh slot -> bone via
// mesh.bone_bindings). Rigid meshes are bound rigidly to bone_bindings[0]. // mesh.bone_bindings). Rigid meshes are bound rigidly to bone_bindings[0].
// Fills out_bounds with the untransformed vertex AABB. // Fills out_bounds with the untransformed vertex AABB.
godot::Ref<godot::ArrayMesh> build_mesh(const gr2::FileInfo &fi, bool flip_winding, godot::Ref<godot::ArrayMesh> build_mesh(const gr2::FileInfo &fi,
const std::vector<RenderPart> &parts, bool flip_winding,
godot::AABB &out_bounds); godot::AABB &out_bounds);
} // namespace mtgodot } // namespace mtgodot
+169 -7
View File
@@ -1,5 +1,6 @@
#include "m2_material.h" #include "m2_material.h"
#include <godot_cpp/classes/image_texture.hpp>
#include <godot_cpp/classes/shader.hpp> #include <godot_cpp/classes/shader.hpp>
#include <godot_cpp/classes/shader_material.hpp> #include <godot_cpp/classes/shader_material.hpp>
#include <godot_cpp/classes/texture2d.hpp> #include <godot_cpp/classes/texture2d.hpp>
@@ -19,15 +20,100 @@ uniform bool use_texture = true;
uniform vec4 modulate : source_color = vec4(1.0); uniform vec4 modulate : source_color = vec4(1.0);
uniform float alpha_scissor : hint_range(0.0, 1.0) = 0.5; uniform float alpha_scissor : hint_range(0.0, 1.0) = 0.5;
uniform int mode = 0; // 0 opaque | 1 alpha-blend | 2 alpha-test (cutout) uniform int mode = 0; // 0 opaque | 1 alpha-blend | 2 alpha-test (cutout)
// sphere-map specular (EterGrnLib/Material.cpp:305 __ApplySpecularRenderState)
uniform sampler2D spec_map : source_color, filter_linear_mipmap, repeat_enable;
uniform float spec_power = 0.0;
uniform bool spec_enable = false;
uniform float lod_fade = 1.0; // LOD crossfade multiplier (1 = fully shown)
void fragment() { void fragment() {
vec4 c = use_texture ? texture(albedo_tex, UV) : vec4(1.0); vec4 c = use_texture ? texture(albedo_tex, UV) : vec4(1.0);
float spec_mask = c.a; // tex.a before modulate == D3DTA_TEXTURE alpha
c *= modulate; // no vertex color: Metin2 PC meshes carry none (ARRAY_COLOR absent) c *= modulate; // no vertex color: Metin2 PC meshes carry none (ARRAY_COLOR absent)
if (mode == 2 && c.a < alpha_scissor) { if (mode == 2 && c.a < alpha_scissor) {
discard; discard;
} }
ALBEDO = c.rgb; ALBEDO = c.rgb;
ALPHA = (mode == 1) ? c.a : 1.0; ALPHA = ((mode == 1) ? c.a : 1.0) * lod_fade;
// Opaque only: client early-outs to plain diffuse when D3DRS_ALPHABLENDENABLE.
// stage1 COLOROP MODULATEALPHA_ADDCOLOR = CURRENT.rgb + CURRENT.a*sphere.rgb,
// CURRENT.a = tex.a * D3DRS_TEXTUREFACTOR.a (= spec_power). texcoord =
// D3DTSS_TCI_CAMERASPACEREFLECTIONVECTOR -> view-space reflect(); .xy as UV.
if (spec_enable && spec_power > 0.0 && mode == 0) {
// Sphere-map metallic sheen (EterGrnLib/Material.cpp:305). The client masks
// this with the armor texture's alpha (metal=1, cloth=0); our loose-file
// texture resolution can't be trusted for that alpha, so bias it to
// grazing angles (Fresnel) and knock the level down — reads as an edge
// sheen instead of a full-body chrome mirror.
vec3 vdir = normalize(VERTEX);
vec3 ndir = normalize(NORMAL);
float fres = pow(clamp(1.0 - abs(dot(ndir, vdir)), 0.0, 1.0), 3.0);
vec3 refl = reflect(vdir, ndir);
EMISSION = texture(spec_map, refl.xy * 0.5 + 0.5).rgb
* (spec_mask * spec_power * fres * 0.5);
}
}
)";
// Skinned variant of SRC_MIX: LBS in vertex() with FULL per-bone matrices from a
// float texture (no Skeleton3D). bones_tex is RGBAF, 3 x bone_count; row = bone,
// texel j = column j of the row-vector skin matrix (skinned = [pos 1] * M).
const char *SRC_SKIN = R"(shader_type spatial;
render_mode blend_mix, depth_draw_opaque, cull_back, diffuse_lambert, specular_disabled;
uniform sampler2D albedo_tex : source_color, filter_linear_mipmap, repeat_enable;
uniform bool use_texture = true;
uniform vec4 modulate : source_color = vec4(1.0);
uniform float alpha_scissor : hint_range(0.0, 1.0) = 0.5;
uniform int mode = 0;
uniform sampler2D bones_tex : filter_nearest; // RGBAF, 3 x bone_count
uniform sampler2D spec_map : source_color, filter_linear_mipmap, repeat_enable;
uniform float spec_power = 0.0;
uniform bool spec_enable = false;
uniform float lod_fade = 1.0; // LOD crossfade multiplier (1 = fully shown)
void vertex() {
vec4 p = vec4(VERTEX, 1.0);
vec3 sp = vec3(0.0);
vec3 sn = vec3(0.0);
ivec4 bi = ivec4(BONE_INDICES);
vec4 bw = BONE_WEIGHTS;
float wsum = bw.x + bw.y + bw.z + bw.w;
if (wsum <= 0.0) { bw = vec4(1.0, 0.0, 0.0, 0.0); wsum = 1.0; }
for (int k = 0; k < 4; k++) {
float w = bw[k] / wsum;
if (w <= 0.0) { continue; }
int r = bi[k];
vec4 c0 = texelFetch(bones_tex, ivec2(0, r), 0);
vec4 c1 = texelFetch(bones_tex, ivec2(1, r), 0);
vec4 c2 = texelFetch(bones_tex, ivec2(2, r), 0);
sp += w * vec3(dot(p, c0), dot(p, c1), dot(p, c2));
sn += w * vec3(dot(NORMAL, c0.xyz), dot(NORMAL, c1.xyz), dot(NORMAL, c2.xyz));
}
VERTEX = sp;
NORMAL = normalize(sn);
}
void fragment() {
vec4 c = use_texture ? texture(albedo_tex, UV) : vec4(1.0);
float spec_mask = c.a;
c *= modulate;
if (mode == 2 && c.a < alpha_scissor) { discard; }
ALBEDO = c.rgb;
ALPHA = ((mode == 1) ? c.a : 1.0) * lod_fade;
if (spec_enable && spec_power > 0.0 && mode == 0) {
// Sphere-map metallic sheen (EterGrnLib/Material.cpp:305). The client masks
// this with the armor texture's alpha (metal=1, cloth=0); our loose-file
// texture resolution can't be trusted for that alpha, so bias it to
// grazing angles (Fresnel) and knock the level down — reads as an edge
// sheen instead of a full-body chrome mirror.
vec3 vdir = normalize(VERTEX);
vec3 ndir = normalize(NORMAL);
float fres = pow(clamp(1.0 - abs(dot(ndir, vdir)), 0.0, 1.0), 3.0);
vec3 refl = reflect(vdir, ndir);
EMISSION = texture(spec_map, refl.xy * 0.5 + 0.5).rgb
* (spec_mask * spec_power * fres * 0.5);
}
} }
)"; )";
@@ -47,13 +133,67 @@ void fragment() {
} }
)"; )";
Ref<Shader> shader_for(bool additive) { // Additive surfaces need the same vertex deformation as opaque/alpha surfaces.
static Ref<Shader> s_mix; // Keeping this as a separate shader preserves blend_add/unshaded render modes.
static Ref<Shader> s_add; const char *SRC_ADD_SKIN = R"(shader_type spatial;
Ref<Shader> &slot = additive ? s_add : s_mix; render_mode blend_add, depth_draw_opaque, depth_test_disabled, cull_back, unshaded;
uniform sampler2D albedo_tex : source_color, filter_linear_mipmap, repeat_enable;
uniform bool use_texture = true;
uniform vec4 modulate : source_color = vec4(1.0);
uniform sampler2D bones_tex : filter_nearest;
void vertex() {
vec4 p = vec4(VERTEX, 1.0);
vec3 sp = vec3(0.0);
vec3 sn = vec3(0.0);
ivec4 bi = ivec4(BONE_INDICES);
vec4 bw = BONE_WEIGHTS;
float wsum = bw.x + bw.y + bw.z + bw.w;
if (wsum <= 0.0) { bw = vec4(1.0, 0.0, 0.0, 0.0); wsum = 1.0; }
for (int k = 0; k < 4; k++) {
float w = bw[k] / wsum;
if (w <= 0.0) { continue; }
int r = bi[k];
vec4 c0 = texelFetch(bones_tex, ivec2(0, r), 0);
vec4 c1 = texelFetch(bones_tex, ivec2(1, r), 0);
vec4 c2 = texelFetch(bones_tex, ivec2(2, r), 0);
sp += w * vec3(dot(p, c0), dot(p, c1), dot(p, c2));
sn += w * vec3(dot(NORMAL, c0.xyz), dot(NORMAL, c1.xyz), dot(NORMAL, c2.xyz));
}
VERTEX = sp;
NORMAL = normalize(sn);
}
void fragment() {
vec4 c = use_texture ? texture(albedo_tex, UV) : vec4(1.0);
c *= modulate;
ALBEDO = c.rgb * c.a;
ALPHA = 1.0;
}
)";
Ref<Shader> s_mix;
Ref<Shader> s_add;
Ref<Shader> s_skin;
Ref<Shader> s_add_skin;
// cull_disabled variants for two-sided parts (hair / cloth / foliage; client
// ExtendedData "Two-sided" -> D3DRS_CULLMODE = D3DCULL_NONE).
Ref<Shader> s_mix_2s;
Ref<Shader> s_add_2s;
Ref<Shader> s_skin_2s;
Ref<Shader> s_add_skin_2s;
Ref<Shader> shader_for(bool additive, bool skinned, bool two_sided) {
Ref<Shader> &slot = two_sided
? (additive ? (skinned ? s_add_skin_2s : s_add_2s) : (skinned ? s_skin_2s : s_mix_2s))
: (additive ? (skinned ? s_add_skin : s_add) : (skinned ? s_skin : s_mix));
if (slot.is_null()) { if (slot.is_null()) {
slot.instantiate(); slot.instantiate();
slot->set_code(additive ? SRC_ADD : SRC_MIX); const char *base =
additive ? (skinned ? SRC_ADD_SKIN : SRC_ADD) : (skinned ? SRC_SKIN : SRC_MIX);
slot->set_code(two_sided ? String(base).replace("cull_back", "cull_disabled")
: String(base));
} }
return slot; return slot;
} }
@@ -65,7 +205,11 @@ Ref<ShaderMaterial> make_material(const MaterialDesc &d) {
m.instantiate(); m.instantiate();
const bool additive = (d.blend == BlendMode::Add); const bool additive = (d.blend == BlendMode::Add);
m->set_shader(shader_for(additive)); const bool skinned = d.skinned;
m->set_shader(shader_for(additive, skinned, d.two_sided));
if (skinned && d.bones_tex.is_valid()) {
m->set_shader_parameter("bones_tex", d.bones_tex);
}
const bool has_tex = d.albedo.is_valid(); const bool has_tex = d.albedo.is_valid();
m->set_shader_parameter("use_texture", has_tex); m->set_shader_parameter("use_texture", has_tex);
@@ -75,6 +219,13 @@ Ref<ShaderMaterial> make_material(const MaterialDesc &d) {
m->set_shader_parameter("modulate", has_tex ? Color(1, 1, 1, 1) : Color(0.8, 0.8, 0.82, 1)); m->set_shader_parameter("modulate", has_tex ? Color(1, 1, 1, 1) : Color(0.8, 0.8, 0.82, 1));
if (!additive) { if (!additive) {
// sphere-map specular (dormant unless spec_power > 0 and a map is bound)
const bool spec = d.spec_power > 0.0f && d.spec_map.is_valid();
m->set_shader_parameter("spec_enable", spec);
m->set_shader_parameter("spec_power", d.spec_power);
if (spec) {
m->set_shader_parameter("spec_map", d.spec_map);
}
m->set_shader_parameter("alpha_scissor", d.alpha_scissor); m->set_shader_parameter("alpha_scissor", d.alpha_scissor);
int mode = 0; int mode = 0;
if (d.blend == BlendMode::Alpha) { if (d.blend == BlendMode::Alpha) {
@@ -92,4 +243,15 @@ Ref<ShaderMaterial> make_material(const MaterialDesc &d) {
return m; return m;
} }
void cleanup_material_shaders() {
s_mix.unref();
s_add.unref();
s_skin.unref();
s_add_skin.unref();
s_mix_2s.unref();
s_add_2s.unref();
s_skin_2s.unref();
s_add_skin_2s.unref();
}
} // namespace mtgodot } // namespace mtgodot
+24
View File
@@ -12,6 +12,7 @@
namespace godot { namespace godot {
class ShaderMaterial; class ShaderMaterial;
class Texture2D; class Texture2D;
class ImageTexture;
} // namespace godot } // namespace godot
namespace mtgodot { namespace mtgodot {
@@ -28,8 +29,31 @@ struct MaterialDesc {
BlendMode blend = BlendMode::Opaque; BlendMode blend = BlendMode::Opaque;
float alpha_scissor = 0.5f; float alpha_scissor = 0.5f;
bool two_sided = true; // winding not yet verified per-model bool two_sided = true; // winding not yet verified per-model
// GPU skinning: LBS with FULL per-bone affine matrices in the vertex shader
// (no Skeleton3D -> no quaternion orthonormalization -> shear preserved).
// `bones_tex` is RGBAF, size 3 x bone_count; columns 0..2 = the 3 columns of
// the row-vector skin matrix (see metin2_model.cpp::gpu_skin).
bool skinned = false;
godot::Ref<godot::ImageTexture> bones_tex;
// Sphere-map specular — port of CGrannyMaterial::__ApplySpecularRenderState
// (EterGrnLib/Material.cpp:305). Opaque armor gets a metallic highlight from a
// camera-space reflection-vector lookup into a shared sphere map, added on top
// of the lit diffuse:
// out.rgb = tex.rgb*modulate + (tex.a * spec_power) * sphere(reflect_uv)
// Client enables this per skin-part only when the equipped body-armor item's
// item_proto `bSpecular > 0` (power = bSpecular/100); the base body is flat.
// Dormant here until an equipment layer feeds a power: spec_power <= 0 ->
// the shader branch is skipped and output is byte-identical to before.
godot::Ref<godot::Texture2D> spec_map; // shared sphere map; null -> disabled
float spec_power = 0.0f; // fSpecularPower (D3DRS_TEXTUREFACTOR.a); 0 = off
}; };
godot::Ref<godot::ShaderMaterial> make_material(const MaterialDesc &d); godot::Ref<godot::ShaderMaterial> make_material(const MaterialDesc &d);
// Drop the extension-owned shader cache before GDExtension teardown. Materials
// still alive in the scene keep their own Ref, so this only removes the static
// lifetime that otherwise survives Godot's leak check.
void cleanup_material_shaders();
} // namespace mtgodot } // namespace mtgodot
+266 -49
View File
@@ -1,5 +1,6 @@
#include "metin2_anim.h" #include "metin2_anim.h"
#include "asset_io.h"
#include "gr2_bridge.h" #include "gr2_bridge.h"
#include "metin2_model.h" #include "metin2_model.h"
@@ -13,14 +14,9 @@
#include <godot_cpp/classes/mesh_instance3d.hpp> #include <godot_cpp/classes/mesh_instance3d.hpp>
#include <godot_cpp/classes/os.hpp> #include <godot_cpp/classes/os.hpp>
#include <godot_cpp/classes/skin.hpp> #include <godot_cpp/classes/skin.hpp>
#include <godot_cpp/classes/file_access.hpp>
static double _tdiff(const godot::Transform3D &a, const godot::Transform3D &b) { #include <godot_cpp/variant/array.hpp>
double d = 0.0; #include <godot_cpp/variant/dictionary.hpp>
for (int r = 0; r < 3; ++r)
for (int c = 0; c < 3; ++c)
d = godot::Math::max(d, (double)godot::Math::abs(a.basis[r][c] - b.basis[r][c]));
return godot::Math::max(d, (double)(a.origin - b.origin).length());
}
using namespace godot; using namespace godot;
@@ -36,25 +32,65 @@ void Metin2AnimPlayer::_bind_methods() {
ClassDB::bind_method(D_METHOD("get_model_path"), &Metin2AnimPlayer::get_model_path); ClassDB::bind_method(D_METHOD("get_model_path"), &Metin2AnimPlayer::get_model_path);
ClassDB::bind_method(D_METHOD("set_playing", "v"), &Metin2AnimPlayer::set_playing); ClassDB::bind_method(D_METHOD("set_playing", "v"), &Metin2AnimPlayer::set_playing);
ClassDB::bind_method(D_METHOD("get_playing"), &Metin2AnimPlayer::get_playing); ClassDB::bind_method(D_METHOD("get_playing"), &Metin2AnimPlayer::get_playing);
ClassDB::bind_method(D_METHOD("set_loop", "v"), &Metin2AnimPlayer::set_loop);
ClassDB::bind_method(D_METHOD("get_loop"), &Metin2AnimPlayer::get_loop);
ClassDB::bind_method(D_METHOD("set_time_scale", "s"), &Metin2AnimPlayer::set_time_scale); ClassDB::bind_method(D_METHOD("set_time_scale", "s"), &Metin2AnimPlayer::set_time_scale);
ClassDB::bind_method(D_METHOD("get_time_scale"), &Metin2AnimPlayer::get_time_scale); ClassDB::bind_method(D_METHOD("get_time_scale"), &Metin2AnimPlayer::get_time_scale);
ClassDB::bind_method(D_METHOD("set_blend_time", "s"), &Metin2AnimPlayer::set_blend_time);
ClassDB::bind_method(D_METHOD("get_blend_time"), &Metin2AnimPlayer::get_blend_time);
ClassDB::bind_method(D_METHOD("set_time", "t"), &Metin2AnimPlayer::set_time); ClassDB::bind_method(D_METHOD("set_time", "t"), &Metin2AnimPlayer::set_time);
ClassDB::bind_method(D_METHOD("get_time"), &Metin2AnimPlayer::get_time); ClassDB::bind_method(D_METHOD("get_time"), &Metin2AnimPlayer::get_time);
ClassDB::bind_method(D_METHOD("get_duration"), &Metin2AnimPlayer::get_duration); ClassDB::bind_method(D_METHOD("get_duration"), &Metin2AnimPlayer::get_duration);
ClassDB::bind_method(D_METHOD("reload"), &Metin2AnimPlayer::reload); ClassDB::bind_method(D_METHOD("reload"), &Metin2AnimPlayer::reload);
ClassDB::bind_method(D_METHOD("get_info"), &Metin2AnimPlayer::get_info); ClassDB::bind_method(D_METHOD("get_info"), &Metin2AnimPlayer::get_info);
ClassDB::bind_method(D_METHOD("selfcheck", "samples"), &Metin2AnimPlayer::selfcheck, DEFVAL(24)); ClassDB::bind_method(D_METHOD("selfcheck", "samples"), &Metin2AnimPlayer::selfcheck, DEFVAL(24));
ClassDB::bind_method(D_METHOD("get_accumulation"), &Metin2AnimPlayer::get_accumulation);
ClassDB::bind_method(D_METHOD("get_events"), &Metin2AnimPlayer::get_events);
ClassDB::bind_method(D_METHOD("get_loop_data"), &Metin2AnimPlayer::get_loop_data);
ADD_PROPERTY(PropertyInfo(Variant::STRING, "anim_path", PROPERTY_HINT_GLOBAL_FILE, "*.gr2"), // Fired when playback time crosses a .msa MotionEventData entry.
ADD_SIGNAL(MethodInfo("motion_event",
PropertyInfo(Variant::INT, "type"),
PropertyInfo(Variant::STRING, "effect"),
PropertyInfo(Variant::STRING, "sound"),
PropertyInfo(Variant::VECTOR3, "pos")));
ADD_SIGNAL(MethodInfo("playback_finished"));
ADD_PROPERTY(PropertyInfo(Variant::STRING, "anim_path", PROPERTY_HINT_GLOBAL_FILE, "*.gr2,*.msa"),
"set_anim_path", "get_anim_path"); "set_anim_path", "get_anim_path");
ADD_PROPERTY(PropertyInfo(Variant::NODE_PATH, "model_path", PROPERTY_HINT_NODE_PATH_VALID_TYPES, "Node3D"), ADD_PROPERTY(PropertyInfo(Variant::NODE_PATH, "model_path", PROPERTY_HINT_NODE_PATH_VALID_TYPES, "Node3D"),
"set_model_path", "get_model_path"); "set_model_path", "get_model_path");
ADD_PROPERTY(PropertyInfo(Variant::BOOL, "playing"), "set_playing", "get_playing"); ADD_PROPERTY(PropertyInfo(Variant::BOOL, "playing"), "set_playing", "get_playing");
ADD_PROPERTY(PropertyInfo(Variant::BOOL, "loop"), "set_loop", "get_loop");
ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "time_scale", PROPERTY_HINT_RANGE, "0,4,0.01"), ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "time_scale", PROPERTY_HINT_RANGE, "0,4,0.01"),
"set_time_scale", "get_time_scale"); "set_time_scale", "get_time_scale");
ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "blend_time", PROPERTY_HINT_RANGE, "0,1,0.01"),
"set_blend_time", "get_blend_time");
} }
void Metin2AnimPlayer::set_anim_path(const String &p) { void Metin2AnimPlayer::set_anim_path(const String &p) {
if (p == anim_path) {
return;
}
// Start a crossfade from the clip that is currently playing.
if (is_inside_tree() && anim_file && blend_time > 0.0) {
prev_anim_file = std::move(anim_file); // anim_file now empty; reload() refills it
prev_anim_duration = duration;
prev_anim_loop = loop;
double pt = time;
if (prev_anim_duration > 0.0) {
if (prev_anim_loop) {
pt = std::fmod(pt, prev_anim_duration);
if (pt < 0.0) {
pt += prev_anim_duration;
}
} else {
pt = std::fmax(0.0, std::fmin(pt, prev_anim_duration));
}
}
prev_anim_time = pt;
blend_elapsed = 0.0;
}
anim_path = p; anim_path = p;
if (is_inside_tree()) { if (is_inside_tree()) {
reload(); reload();
@@ -64,7 +100,8 @@ void Metin2AnimPlayer::set_model_path(const NodePath &p) {
model_path = p; model_path = p;
} }
void Metin2AnimPlayer::set_time(double t) { void Metin2AnimPlayer::set_time(double t) {
time = t; time = (!loop && duration > 0.0) ? std::fmax(0.0, std::fmin(t, duration)) : t;
prev_time = time; // scrubbing must not replay every event since the old cursor
if (is_inside_tree()) { if (is_inside_tree()) {
apply_pose(time); apply_pose(time);
} }
@@ -78,6 +115,78 @@ Metin2Model *Metin2AnimPlayer::resolve_model() const {
return Object::cast_to<Metin2Model>(n); return Object::cast_to<Metin2Model>(n);
} }
// "d:\Ymir Work\pc\warrior\action\dance.gr2" -> "<root>/PC/ymir work/pc/warrior/action/dance.gr2"
// where <root> is found by walking up from the .msa's own dir past "ymir work".
// Falls back to <msa_dir>/<basename> (the anim gr2 is usually right next to it).
static String strip_dpath(const String &raw) {
String p = raw.replace("\\", "/");
int k = p.to_lower().find("ymir work");
return k >= 0 ? p.substr(k) : p; // "ymir work/pc/.../x.gr2"
}
String Metin2AnimPlayer::resolve_anim_gr2(const String &spec) {
String low = spec.to_lower();
if (!low.ends_with(".msa")) {
return spec; // already a .gr2 path
}
fmt::Msa m;
std::string e;
if (!fmt::parse_msa_file(std::string(spec.utf8().get_data()), m, &e)) {
UtilityFunctions::push_error(String("[Metin2AnimPlayer] .msa parse: ") + String(e.c_str()));
return spec;
}
const String motion = String(m.motion_gr2.c_str());
const String base = motion.replace("\\", "/").get_file();
const String msa_dir = spec.get_base_dir();
// 1. sibling of the .msa
String cand = msa_dir.path_join(base);
if (FileAccess::file_exists(cand)) {
return cand;
}
// 2. <root>/PC/<ymir-work tail>, root = ancestor of the .msa above "ymir work"
String tail = strip_dpath(motion); // "ymir work/pc/.../x.gr2"
int ky = msa_dir.to_lower().find("ymir work");
if (ky > 0) {
String root = spec.substr(0, ky); // ".../assets/PC/"
cand = root.path_join(tail.substr(String("ymir work/").length()));
if (FileAccess::file_exists(cand)) {
return cand;
}
cand = root.get_base_dir().path_join(tail); // ".../assets/" + "ymir work/..."
if (FileAccess::file_exists(cand)) {
return cand;
}
}
UtilityFunctions::push_warning(String("[Metin2AnimPlayer] .msa motion gr2 not found: ") + motion +
" (tried " + msa_dir.path_join(base) + ")");
return cand;
}
Array Metin2AnimPlayer::get_events() const {
Array a;
for (const fmt::MotionEvent &ev : events) {
Dictionary d;
d["type"] = ev.type;
d["start_time"] = ev.start_time;
d["effect"] = String(ev.effect_file.c_str());
d["sound"] = String(ev.sound_file.c_str());
d["pos"] = Vector3(ev.position[0], ev.position[1], ev.position[2]);
a.push_back(d);
}
return a;
}
Dictionary Metin2AnimPlayer::get_loop_data() const {
Dictionary d;
d["present"] = msa_metadata.has_loop_data;
d["count"] = msa_metadata.motion_loop_count;
d["cancel_enable"] = msa_metadata.loop_cancel_enable;
d["start_time"] = msa_metadata.loop_start_time;
d["end_time"] = msa_metadata.loop_end_time;
return d;
}
void Metin2AnimPlayer::_ready() { void Metin2AnimPlayer::_ready() {
set_process(true); set_process(true);
reload(); reload();
@@ -87,13 +196,38 @@ void Metin2AnimPlayer::reload() {
anim_file = std::nullopt; anim_file = std::nullopt;
duration = 0.0; duration = 0.0;
time = 0.0; time = 0.0;
prev_time = 0.0;
next_event = 0;
accumulation = Vector3();
events.clear();
msa_metadata = fmt::Msa{};
last_info = ""; last_info = "";
if (anim_path.is_empty()) { if (anim_path.is_empty()) {
return; return;
} }
// .msa -> resolve the real motion .gr2 + pull accumulation / events.
String gr2_spec = anim_path;
if (anim_path.to_lower().ends_with(".msa")) {
fmt::Msa m;
std::string e;
if (fmt::parse_msa_file(std::string(anim_path.utf8().get_data()), m, &e)) {
msa_metadata = m;
accumulation = Vector3(m.accumulation[0], m.accumulation[1], m.accumulation[2]);
events = m.events;
gr2_spec = resolve_anim_gr2(anim_path);
UtilityFunctions::print(vformat("[Metin2AnimPlayer] .msa -> %s accum=(%.2f %.2f %.2f) events=%d loopdata=%s",
gr2_spec, accumulation.x, accumulation.y, accumulation.z, (int)events.size(),
m.has_loop_data ? vformat("%d x [%.3f,%.3f]", m.motion_loop_count,
m.loop_start_time, m.loop_end_time) : String("none")));
} else {
UtilityFunctions::push_error(String("[Metin2AnimPlayer] .msa: ") + String(e.c_str()));
return;
}
}
gr2::LoadError err; gr2::LoadError err;
const std::string p(anim_path.utf8().get_data()); auto f = mtgodot::gr2_from_file(gr2_spec, &err);
auto f = gr2::File::load_path(p, &err);
if (!f) { if (!f) {
UtilityFunctions::push_error(String("[Metin2AnimPlayer] anim load failed [") + UtilityFunctions::push_error(String("[Metin2AnimPlayer] anim load failed [") +
String(err.stage.c_str()) + "]: " + String(err.message.c_str())); String(err.stage.c_str()) + "]: " + String(err.message.c_str()));
@@ -139,6 +273,36 @@ void Metin2AnimPlayer::reload() {
apply_pose(0.0); apply_pose(0.0);
} }
namespace {
// mul4x3 lives in gr2_bridge.h now (shared with the weapon-attach grip compose).
// Blend two bone world transforms: slerp rotation, lerp translation & scale
// (PARITY §2.9). Shear on the ~few scaleshear bones is dropped for the ≤blend_time
// transient only — the doc sanctions "quat slerp + pos/scale lerp".
gr2::Mat4 blend_trs(const gr2::Mat4 &a, const gr2::Mat4 &b, float w) {
godot::Basis ba(godot::Vector3(a[0], a[1], a[2]), godot::Vector3(a[4], a[5], a[6]),
godot::Vector3(a[8], a[9], a[10]));
godot::Basis bb(godot::Vector3(b[0], b[1], b[2]), godot::Vector3(b[4], b[5], b[6]),
godot::Vector3(b[8], b[9], b[10]));
godot::Quaternion q = ba.get_rotation_quaternion().slerp(bb.get_rotation_quaternion(), w);
godot::Vector3 s = ba.get_scale().lerp(bb.get_scale(), w);
godot::Vector3 tt =
godot::Vector3(a[12], a[13], a[14]).lerp(godot::Vector3(b[12], b[13], b[14]), w);
godot::Basis rb(q);
rb.rows[0] *= s.x;
rb.rows[1] *= s.y;
rb.rows[2] *= s.z;
gr2::Mat4 o{};
o[0] = rb.rows[0].x; o[1] = rb.rows[0].y; o[2] = rb.rows[0].z;
o[4] = rb.rows[1].x; o[5] = rb.rows[1].y; o[6] = rb.rows[1].z;
o[8] = rb.rows[2].x; o[9] = rb.rows[2].y; o[10] = rb.rows[2].z;
o[12] = tt.x; o[13] = tt.y; o[14] = tt.z; o[15] = 1.0f;
return o;
}
} // namespace
void Metin2AnimPlayer::apply_pose(double t) { void Metin2AnimPlayer::apply_pose(double t) {
if (!anim_file) { if (!anim_file) {
return; return;
@@ -155,61 +319,114 @@ void Metin2AnimPlayer::apply_pose(double t) {
const gr2::Animation &an = anim_file->file_info().animations[0]; const gr2::Animation &an = anim_file->file_info().animations[0];
double tt = t; double tt = t;
if (duration > 0.0) { if (duration > 0.0 && loop) {
tt = std::fmod(t, duration); tt = std::fmod(t, duration);
if (tt < 0.0) { if (tt < 0.0) {
tt += duration; tt += duration;
} }
} else if (duration > 0.0) {
tt = std::fmax(0.0, std::fmin(t, duration));
} }
gr2::sample_pose(*sk, an, (float)tt, world_buf, skin_buf); gr2::sample_pose(*sk, an, (float)tt, world_buf, skin_buf);
if (godot::OS::get_singleton()->get_environment("MTGODOT_CPUSKIN") == "1") { // Crossfade: blend the frozen outgoing-clip pose into this one, then rebuild
// skin_buf = invWorld · world_blended (PARITY §2.9).
if (prev_anim_file && blend_time > 0.0 && blend_elapsed < blend_time) {
const gr2::FileInfo &pfi = prev_anim_file->file_info();
if (!pfi.animations.empty()) {
gr2::sample_pose(*sk, pfi.animations[0], (float)prev_anim_time, prev_world_buf,
prev_skin_buf);
double w = blend_elapsed / blend_time;
// ease-in on the incoming clip, matching the client's
// GrannySetControlEaseInCurve(t0,t1, 0,0,1,1) Hermite (p0=0,m0=0,
// p1=1,m1=1) -> h(w) = 2w^2 - w^3. Flat start, slope-1 finish.
w = w * w * (2.0 - w);
const size_t n = std::min(world_buf.size(), prev_world_buf.size());
blend_world_buf.resize(world_buf.size());
for (size_t i = 0; i < n; ++i)
blend_world_buf[i] = blend_trs(prev_world_buf[i], world_buf[i], (float)w);
for (size_t i = n; i < world_buf.size(); ++i)
blend_world_buf[i] = world_buf[i];
const auto &bones = sk->bones;
for (size_t i = 0; i < world_buf.size() && i < bones.size(); ++i)
skin_buf[i] = mul4x3(bones[i].inverse_world, blend_world_buf[i]);
world_buf.swap(blend_world_buf); // weapon attach follows the blended hand
}
}
// Both skinning paths apply libgr2's per-bone deformer matrices
// (skin_buf = Σ w · invWorld · world) with the FULL affine — shear kept.
// default : CPU LBS, ArrayMesh rebuilt each frame.
// MTGODOT_GPUSKIN=1 : LBS in a custom vertex shader (bone matrices in a
// float texture, no Skeleton3D). See m2_material SRC_SKIN.
// The old Skeleton3D::set_bone_pose route is gone — Godot's built-in skinning
// orthonormalizes the bone matrix and drops the shear (docs/MIDREVIEW.md §4).
if (godot::OS::get_singleton()->get_environment("MTGODOT_GPUSKIN") == "1") {
model->enable_gpu_skin(true);
model->gpu_skin(skin_buf);
} else {
model->enable_cpu_skin(true); model->enable_cpu_skin(true);
model->cpu_skin(skin_buf); model->cpu_skin(skin_buf);
return;
}
// Convert gr2 global poses -> Godot *local* bone poses and set those.
// (Setting global poses directly hits a parent-ordering hazard in
// Skeleton3D::set_bone_global_pose that detaches head/upper-body meshes
// once bones leave the bind pose.)
const int n = (int)std::min<size_t>(world_buf.size(),
std::min<size_t>(sk->bones.size(), (size_t)skel->get_bone_count()));
for (int i = 0; i < n; ++i) {
const godot::Transform3D t_i = gr2_to_godot(world_buf[i]);
const int parent = sk->bones[i].parent;
if (parent < 0 || parent >= n) {
skel->set_bone_pose(i, t_i);
} else {
skel->set_bone_pose(i, gr2_to_godot(world_buf[parent]).affine_inverse() * t_i);
}
}
if (!verified_ && OS::get_singleton()->get_environment("MTGODOT_VERIFY") == "1") {
verified_ = true;
skel->force_update_all_bone_transforms();
double d_pose = 0.0;
int w_pose = -1;
for (int i = 0; i < n; ++i) {
double dp = _tdiff(skel->get_bone_global_pose(i), gr2_to_godot(world_buf[i]));
if (dp > d_pose) {
d_pose = dp;
w_pose = i;
}
}
UtilityFunctions::print(vformat(
"[Metin2AnimPlayer] verify: max |global_pose - conv(world)| = %.6f @ bone %d '%s'",
d_pose, w_pose, w_pose >= 0 ? String(sk->bones[w_pose].name.c_str()) : String()));
} }
// Rigid weapon follows equip_right_hand's animated world transform (PARITY §2.1).
model->update_weapon_pose(world_buf);
} }
void Metin2AnimPlayer::_process(double delta) { void Metin2AnimPlayer::_process(double delta) {
if (!playing || !anim_file) { if (!playing || !anim_file) {
return; return;
} }
time += delta * time_scale; if (prev_anim_file) {
blend_elapsed += delta; // real seconds, independent of time_scale
if (blend_elapsed >= blend_time) {
prev_anim_file = std::nullopt;
}
}
double next = time + delta * time_scale;
if (!loop && duration > 0.0 && next >= duration) {
next = duration;
}
time = next;
apply_pose(time); apply_pose(time);
dispatch_events(prev_time, time);
prev_time = time;
if (!loop && duration > 0.0 && time >= duration) {
playing = false;
emit_signal("playback_finished");
}
}
// Emit `motion_event` for every .msa event whose start_time falls in (from, to],
// handling loop wrap-around within one clip.
void Metin2AnimPlayer::dispatch_events(double from, double to) {
if (events.empty() || duration <= 0.0) {
return;
}
if (!loop) {
double a = std::fmax(0.0, std::fmin(from, duration));
double b = std::fmax(0.0, std::fmin(to, duration));
for (const fmt::MotionEvent &ev : events) {
if (a < ev.start_time && ev.start_time <= b) {
emit_signal("motion_event", ev.type, String(ev.effect_file.c_str()),
String(ev.sound_file.c_str()),
Vector3(ev.position[0], ev.position[1], ev.position[2]));
}
}
return;
}
double a = std::fmod(from, duration);
if (a < 0) a += duration;
double b = a + (to - from);
for (const fmt::MotionEvent &ev : events) {
double t = ev.start_time;
bool hit = (a < t && t <= b) || (b > duration && t <= b - duration);
if (hit) {
emit_signal("motion_event", ev.type, String(ev.effect_file.c_str()),
String(ev.sound_file.c_str()),
Vector3(ev.position[0], ev.position[1], ev.position[2]));
}
}
} }
String Metin2AnimPlayer::selfcheck(int samples) { String Metin2AnimPlayer::selfcheck(int samples) {
+45 -7
View File
@@ -1,10 +1,13 @@
#pragma once #pragma once
#include <godot_cpp/classes/node3d.hpp> #include <godot_cpp/classes/node3d.hpp>
#include <godot_cpp/variant/dictionary.hpp>
#include <godot_cpp/variant/node_path.hpp> #include <godot_cpp/variant/node_path.hpp>
#include <godot_cpp/variant/string.hpp> #include <godot_cpp/variant/string.hpp>
#include <godot_cpp/variant/vector3.hpp>
#include <gr2/gr2.h> #include <gr2/gr2.h>
#include <msa.h>
#include <optional> #include <optional>
#include <vector> #include <vector>
@@ -13,14 +16,18 @@ namespace mtgodot {
class Metin2Model; class Metin2Model;
// Drives a Metin2Model's Skeleton3D each frame by sampling a (possibly // Drives a Metin2Model each frame by sampling a (possibly separate) animation
// separate) animation .gr2 with libgr2, then writing per-bone global poses. // .gr2 with libgr2, then skinning it with the resulting matrices:
// //
// world[i] = gr2::sample_pose(model.skeleton, anim, t) // sample_pose(model.skeleton, anim, t) -> world[i], skin[i]
// skel.set_bone_global_pose(i, gr2_to_godot(world[i])) // skin[i] = inverse_world[i] * world[i] (gr2 deformer matrix per bone)
// //
// Godot's renderer then computes skin matrix = global_pose(i) * bind_pose(i) // The skinning itself is done in Metin2Model (NOT via Skeleton3D — Godot's
// which equals gr2's deformer matrix (see gr2_bridge.h). // built-in skinning orthonormalizes the bone matrix and drops the shear Granny
// bakes onto some rig bones; see docs/MIDREVIEW.md §4):
// - default : cpu_skin(skin[]) — CPU LBS, ArrayMesh rebuilt/frame
// - MTGODOT_GPUSKIN=1 : gpu_skin(skin[]) — full 4x3 matrices in a float
// texture, LBS in the SRC_SKIN vertex shader
class Metin2AnimPlayer : public godot::Node3D { class Metin2AnimPlayer : public godot::Node3D {
GDCLASS(Metin2AnimPlayer, godot::Node3D) GDCLASS(Metin2AnimPlayer, godot::Node3D)
@@ -39,10 +46,17 @@ public:
void set_playing(bool v) { playing = v; } void set_playing(bool v) { playing = v; }
bool get_playing() const { return playing; } bool get_playing() const { return playing; }
void set_loop(bool v) { loop = v; }
bool get_loop() const { return loop; }
void set_time_scale(double s) { time_scale = s; } void set_time_scale(double s) { time_scale = s; }
double get_time_scale() const { return time_scale; } double get_time_scale() const { return time_scale; }
// Crossfade duration (s) applied when anim_path changes while a clip is
// already playing (PARITY §2.9). 0 = hard cut (old behaviour).
void set_blend_time(double s) { blend_time = s < 0.0 ? 0.0 : s; }
double get_blend_time() const { return blend_time; }
void set_time(double t); void set_time(double t);
double get_time() const { return time; } double get_time() const { return time; }
@@ -50,6 +64,11 @@ public:
void reload(); void reload();
godot::String get_info() const { return last_info; } godot::String get_info() const { return last_info; }
// .msa metadata (empty / zero when anim_path is a raw .gr2).
godot::Vector3 get_accumulation() const { return accumulation; }
godot::Array get_events() const; // [{type,start_time,effect,sound,pos}, ...]
godot::Dictionary get_loop_data() const;
// NaN-scan every animation in anim_path across [0,dur]; returns a report string. // NaN-scan every animation in anim_path across [0,dur]; returns a report string.
godot::String selfcheck(int samples = 24); godot::String selfcheck(int samples = 24);
@@ -60,18 +79,37 @@ private:
godot::String anim_path; godot::String anim_path;
godot::NodePath model_path; godot::NodePath model_path;
bool playing = true; bool playing = true;
bool loop = true; // whole-clip playback mode; independent from .msa LoopData
double time_scale = 1.0; double time_scale = 1.0;
double time = 0.0; double time = 0.0;
double duration = 0.0; double duration = 0.0;
bool verified_ = false;
godot::String last_info; godot::String last_info;
std::optional<gr2::File> anim_file; std::optional<gr2::File> anim_file;
std::vector<gr2::Mat4> world_buf; std::vector<gr2::Mat4> world_buf;
std::vector<gr2::Mat4> skin_buf; std::vector<gr2::Mat4> skin_buf;
// Crossfade state: the outgoing clip is kept and sampled at a frozen time,
// its pose blended into the incoming clip for `blend_time` seconds.
double blend_time = 0.15;
std::optional<gr2::File> prev_anim_file;
double prev_anim_time = 0.0;
double prev_anim_duration = 0.0;
bool prev_anim_loop = true;
double blend_elapsed = 0.0;
std::vector<gr2::Mat4> prev_world_buf, prev_skin_buf, blend_world_buf;
godot::Vector3 accumulation; // .msa Accumulation (root motion)
std::vector<fmt::MotionEvent> events;
fmt::Msa msa_metadata;
int next_event = 0; // index into `events`, for _process dispatch
double prev_time = 0.0;
Metin2Model *resolve_model() const; Metin2Model *resolve_model() const;
void apply_pose(double t); void apply_pose(double t);
void dispatch_events(double from, double to);
// .msa path -> real motion .gr2 path; passes plain .gr2 paths through.
static godot::String resolve_anim_gr2(const godot::String &spec);
}; };
} // namespace mtgodot } // namespace mtgodot
File diff suppressed because it is too large Load Diff
+136 -2
View File
@@ -1,15 +1,19 @@
#pragma once #pragma once
#include <godot_cpp/classes/array_mesh.hpp> #include <godot_cpp/classes/array_mesh.hpp>
#include <godot_cpp/classes/image.hpp>
#include <godot_cpp/classes/image_texture.hpp> #include <godot_cpp/classes/image_texture.hpp>
#include <godot_cpp/classes/node3d.hpp> #include <godot_cpp/classes/node3d.hpp>
#include <godot_cpp/classes/ref.hpp> #include <godot_cpp/classes/ref.hpp>
#include <godot_cpp/templates/hash_map.hpp> #include <godot_cpp/templates/hash_map.hpp>
#include <godot_cpp/variant/array.hpp>
#include <godot_cpp/variant/packed_string_array.hpp> #include <godot_cpp/variant/packed_string_array.hpp>
#include <godot_cpp/variant/string.hpp> #include <godot_cpp/variant/string.hpp>
#include <gr2/gr2.h> #include <gr2/gr2.h>
#include "gr2_bridge.h"
#include <memory> #include <memory>
#include <optional> #include <optional>
#include <vector> #include <vector>
@@ -35,8 +39,17 @@ public:
~Metin2Model() override; ~Metin2Model() override;
void _ready() override; void _ready() override;
void _process(double delta) override;
// --- inspector properties --- // --- inspector properties ---
// §2.10 LOD: loads <base>_lod_01/02/03.gr2 (same 75-bone skeleton, decimated
// meshes) and swaps the rendered mesh by camera distance. `lod_distances` =
// [d1,d2,d3]; >d3 -> LOD3. Skinning is unaffected (shared skeleton).
void set_lod_enabled(bool v);
bool get_lod_enabled() const { return lod_enabled; }
void set_lod_distances(const godot::PackedFloat32Array &d);
godot::PackedFloat32Array get_lod_distances() const { return lod_dist; }
int get_lod_level() const { return lod_level; }
void set_gr2_path(const godot::String &p); void set_gr2_path(const godot::String &p);
godot::String get_gr2_path() const { return gr2_path; } godot::String get_gr2_path() const { return gr2_path; }
@@ -58,9 +71,56 @@ public:
void set_use_gr2_materials(bool v); void set_use_gr2_materials(bool v);
bool get_use_gr2_materials() const { return use_gr2_materials; } bool get_use_gr2_materials() const { return use_gr2_materials; }
// Sphere-map specular power (PARITY §2.7 / BACKLOG B9). In the original client
// this is per skin-part, driven by the equipped body-armor item_proto
// `bSpecular / 100`; 0 = flat (the base body). No equipment layer here yet, so
// this is a manual hook — 0 keeps output identical to before.
void set_specular_power(double p);
double get_specular_power() const { return specular_power; }
// Explicit per-surface texture path override (index -> absolute .dds path). // Explicit per-surface texture path override (index -> absolute .dds path).
void set_surface_texture(int surface, const godot::String &path); void set_surface_texture(int surface, const godot::String &path);
// When gr2_path is a .msm: [{index, model (abs .gr2), target_skin}, ...].
godot::Array get_hair_options() const { return hair_options; }
// Attach a hair .gr2 whose skeleton bone names match the base. Its mesh is
// merged into the CPU-skin path (skinned by the base skeleton's matrices,
// bones remapped by name). "" detaches. GPU-skin path ignores hair for now.
void set_hair_gr2(const godot::String &p);
godot::String get_hair_gr2() const { return hair_gr2; }
// SourceSkin -> TargetSkin recolour (PARITY §2.4 / BACKLOG D3). The client's
// `.msm` HairData ships one hair .gr2 with a `SourceSkin` (the texture baked
// into the gr2 material) and a per-colour `TargetSkin` dds; `SetMaterialImage
// Pointer(part, SourceSkin, load(TargetSkin))` swaps it. Here: when set, the
// hair mesh uses this dds as its albedo instead of the gr2's sibling texture.
// Absolute path (an entry's resolved `target_skin` from get_hair_options()).
void set_hair_skin(const godot::String &p);
godot::String get_hair_skin() const { return hair_skin; }
// Attach a rigid weapon .gr2 to a base-skeleton bone (PARITY §2.1 / BACKLOG
// C5). The client links the weapon model instance to `equip_right_hand`
// (`playersettingmodule.py`, warrior) and drives it with that bone's world
// matrix (`ModelInstanceUpdate.cpp:148` GetBoneMatrixPointer). Here the weapon
// mesh is a child MeshInstance3D whose transform = the bone's world pose from
// gr2::sample_pose each frame. "" detaches. Path may be relative to the base
// gr2 ("d:/ymir work/item/weapon/00040.gr2") or absolute.
void set_weapon_gr2(const godot::String &p);
godot::String get_weapon_gr2() const { return weapon_gr2; }
void set_weapon_bone(const godot::String &b);
godot::String get_weapon_bone() const { return weapon_bone; }
// Off-hand shield (rigid, attaches to equip_left_hand like the weapon).
void set_shield_gr2(const godot::String &p);
godot::String get_shield_gr2() const { return shield_gr2; }
void set_shield_bone(const godot::String &b);
godot::String get_shield_bone() const { return shield_bone; }
// Fed by Metin2AnimPlayer after each gr2::sample_pose: base-skeleton world
// matrices (world_pose output). Repositions attached rigid parts (weapon +
// shield). No-op for a slot with no gr2 or an unresolved bone name.
void update_weapon_pose(const std::vector<gr2::Mat4> &world_pose);
// Rebuild the subtree from the current properties. // Rebuild the subtree from the current properties.
void reload(); void reload();
@@ -71,6 +131,9 @@ public:
const gr2::Skeleton *gr2_skeleton() const; const gr2::Skeleton *gr2_skeleton() const;
godot::Skeleton3D *skeleton_node() const { return skel; } godot::Skeleton3D *skeleton_node() const { return skel; }
const gr2::FileInfo *gr2_fileinfo() const; const gr2::FileInfo *gr2_fileinfo() const;
// Latest full-affine deformer matrices. Initialized to bind pose on reload
// and refreshed by either skinning path; attachments can share this pose.
const std::vector<gr2::Mat4> &current_skin_matrices() const { return current_skin; }
// CPU linear-blend skinning: rewrite ArrayMesh vertex regions from the // CPU linear-blend skinning: rewrite ArrayMesh vertex regions from the
// per-bone deformer matrices (gr2::sample_pose's `skin` output). Debug/ // per-bone deformer matrices (gr2::sample_pose's `skin` output). Debug/
@@ -79,6 +142,13 @@ public:
bool has_cpu_skin_mesh() const { return cpu_mesh.is_valid(); } bool has_cpu_skin_mesh() const { return cpu_mesh.is_valid(); }
void enable_cpu_skin(bool on); void enable_cpu_skin(bool on);
// GPU linear-blend skinning: LBS in a custom vertex shader with the FULL
// per-bone matrices uploaded as a float texture (no Skeleton3D -> shear kept).
// Keeps the static build_mesh() output; just swaps materials + feeds the
// bone texture each frame. MTGODOT_GPUSKIN=1 route.
void enable_gpu_skin(bool on);
void gpu_skin(const std::vector<gr2::Mat4> &skin);
// libgr2 sanity probe (kept from M0'). // libgr2 sanity probe (kept from M0').
godot::String probe_gr2(const godot::String &path) const; godot::String probe_gr2(const godot::String &path) const;
@@ -93,8 +163,47 @@ private:
bool flip_winding = false; bool flip_winding = false;
godot::String material_mode = "metin2"; // "metin2" (ShaderMaterial) | "standard" godot::String material_mode = "metin2"; // "metin2" (ShaderMaterial) | "standard"
bool use_gr2_materials = false; // drive textures from gr2 material names (unverified) bool use_gr2_materials = true; // gr2 MaterialBindings -> texture (else: filename heuristic only)
double specular_power = 0.0; // PARITY §2.7; 0 = disabled
godot::Ref<godot::Texture2D> sphere_map; // shared sphere map, lazy-loaded
godot::Ref<godot::Texture2D> _load_sphere_map(); // "ymir work/special/spheremap.jpg"
godot::PackedStringArray surface_tex_override; godot::PackedStringArray surface_tex_override;
godot::String resolved_gr2_dir; // dir of the actually-loaded .gr2 (for .msm)
godot::Array hair_options; // from .msm HairData
godot::String hair_gr2; // attached hair .gr2 (or "")
godot::String hair_skin; // TargetSkin dds override (or "")
std::shared_ptr<std::optional<gr2::File>> hair_file; // loaded hair gr2
std::vector<mtgodot::RenderPart> hair_parts;
std::vector<int> hair_bone_remap; // hair skel idx -> base skel idx (by name)
godot::Ref<godot::Material> hair_mat;
godot::Ref<godot::ImageTexture> hair_tex; // resolved hair albedo (for GPU path)
int gpu_base_surf = 0; // base surfaces before appended GPU hair
void _load_hair();
void _build_gpu_mesh(); // base + remapped hair surfaces -> mi->mesh (GPU-skin path, §2.2)
godot::String weapon_gr2; // attached weapon .gr2 (or "")
godot::String weapon_bone = "equip_right_hand"; // base-skeleton attach bone
std::shared_ptr<std::optional<gr2::File>> weapon_file; // loaded weapon gr2
godot::MeshInstance3D *weapon_mi = nullptr; // child of this node
int weapon_bone_idx = -1; // base skeleton index of weapon_bone
// weapon's own bone[0] grip transform (invWorld · local); folded in so the mesh
// aligns to the hand even when it is authored offset from its bone (client:
// GrannySampleModelAnimationsAccelerated on the weapon skeleton). Identity when
// the weapon has no skeleton or a ~identity bone.
gr2::Mat4 weapon_pre{ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 };
godot::String shield_gr2;
godot::String shield_bone = "Bip01 L Hand"; // PC skeletons have no equip_left_hand
std::shared_ptr<std::optional<gr2::File>> shield_file;
godot::MeshInstance3D *shield_mi = nullptr;
int shield_bone_idx = -1;
gr2::Mat4 shield_pre{ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 };
void _load_weapon();
void _load_shield();
// shared rigid-attach loader used by _load_weapon / _load_shield.
void _load_attach(const godot::String &gr2_rel, const godot::String &bone_name,
std::shared_ptr<std::optional<gr2::File>> &slot_file, godot::MeshInstance3D *&slot_mi,
int &slot_bone_idx, gr2::Mat4 &slot_pre, const char *node_name, const char *label);
std::shared_ptr<std::optional<gr2::File>> file; // shared_ptr so accessor stays valid std::shared_ptr<std::optional<gr2::File>> file; // shared_ptr so accessor stays valid
std::vector<gr2::MaterialInfo> materials; std::vector<gr2::MaterialInfo> materials;
@@ -103,12 +212,37 @@ private:
godot::String last_info; godot::String last_info;
godot::HashMap<godot::String, godot::Ref<godot::ImageTexture>> tex_cache; godot::HashMap<godot::String, godot::Ref<godot::ImageTexture>> tex_cache;
// §2.10 LOD
bool lod_enabled = true;
int lod_level = 0; // 0 = full model, 1..3 = _lod_0N
godot::PackedFloat32Array lod_dist;
std::vector<std::shared_ptr<std::optional<gr2::File>>> lod_files; // [0]=_lod_01 ...
std::vector<std::vector<mtgodot::RenderPart>> lod_parts; // parallel to lod_files
// LOD crossfade: a frozen ghost of the outgoing level fades out while the new
// mesh fades in (LODController::BlendRenderWithOneTexture). ~0.18 s.
godot::MeshInstance3D *lod_prev_mi = nullptr;
double lod_fade_t = -1.0; // <0 = not fading
std::vector<godot::Ref<godot::Material>> lod_ghost_mats;
void _end_lod_fade();
std::vector<mtgodot::RenderPart> base_parts;
void _load_lods(const godot::String &loaded_spec);
void _set_lod(int n);
const gr2::FileInfo *active_fi() const; // base or current LOD (mesh/material data)
std::vector<mtgodot::RenderPart> parts; // surface s -> (gr2 mesh, tri_group, material)
std::vector<godot::Ref<godot::Material>> surf_mats; // resolved once; re-assigned each cpu_skin frame
godot::Ref<godot::ArrayMesh> cpu_mesh; // rebuilt each frame in cpu_skin mode godot::Ref<godot::ArrayMesh> cpu_mesh; // rebuilt each frame in cpu_skin mode
std::vector<int> cpu_surf_mesh; // surface -> gr2 mesh index bool gpu_skin_active = false;
std::vector<gr2::Mat4> current_skin;
godot::Ref<godot::Image> bones_img; // RGBAF 3 x bone_count
godot::Ref<godot::ImageTexture> bones_tex;
void _clear_children(); void _clear_children();
void _apply_materials(); void _apply_materials();
godot::String _guess_texture_dir() const; godot::String _guess_texture_dir() const;
// Resolve a "d:/ymir work/..." path referenced from `base` (.msm/.msa) to an
// absolute file. Passes existing absolute paths through.
static godot::String resolve_rel_gr2(const godot::String &base, const godot::String &spec);
godot::Ref<godot::ImageTexture> _load_dds(const godot::String &path); godot::Ref<godot::ImageTexture> _load_dds(const godot::String &path);
godot::Ref<godot::ImageTexture> _resolve_texture(const godot::String &dir, godot::Ref<godot::ImageTexture> _resolve_texture(const godot::String &dir,
const godot::String &stem, const godot::String &surface_name, const godot::String &stem, const godot::String &surface_name,
+789
View File
@@ -0,0 +1,789 @@
#include "metin2_world.h"
#include <godot_cpp/classes/array_mesh.hpp>
#include <godot_cpp/classes/box_shape3d.hpp>
#include <godot_cpp/classes/collision_shape3d.hpp>
#include <godot_cpp/classes/file_access.hpp>
#include <godot_cpp/classes/geometry_instance3d.hpp>
#include <godot_cpp/classes/height_map_shape3d.hpp>
#include <godot_cpp/classes/image_texture.hpp>
#include <godot_cpp/classes/static_body3d.hpp>
#include <godot_cpp/classes/mesh_instance3d.hpp>
#include <godot_cpp/classes/multi_mesh.hpp>
#include <godot_cpp/classes/multi_mesh_instance3d.hpp>
#include <godot_cpp/classes/performance.hpp>
#include <godot_cpp/classes/standard_material3d.hpp>
#include <godot_cpp/classes/time.hpp>
#include <godot_cpp/core/class_db.hpp>
#include <godot_cpp/variant/basis.hpp>
#include <godot_cpp/variant/packed_int32_array.hpp>
#include <godot_cpp/variant/packed_vector2_array.hpp>
#include <godot_cpp/variant/packed_vector3_array.hpp>
#include <godot_cpp/variant/transform3d.hpp>
#include <godot_cpp/variant/utility_functions.hpp>
#include "asset_io.h"
#include "gr2_bridge.h"
#include <area_data.h>
#include <environment.h>
#include <m2_coord.h>
#include <property.h>
#include <splat.h>
#include <terrain_mesh.h>
#include "dxt.h"
#include "environment_builder.h"
#include "static_object.h"
#include "water_builder.h"
#include "terrain_splat.h"
#include "tree_placeholder.h"
#include <map>
#include <vector>
#include <algorithm>
#include <cctype>
#include <cmath>
using namespace godot;
namespace mtgodot {
Metin2World::Metin2World() {}
Metin2World::~Metin2World() {}
void Metin2World::_bind_methods() {
ClassDB::bind_method(D_METHOD("set_assets_root", "p"), &Metin2World::set_assets_root);
ClassDB::bind_method(D_METHOD("get_assets_root"), &Metin2World::get_assets_root);
ClassDB::bind_method(D_METHOD("set_map_path", "p"), &Metin2World::set_map_path);
ClassDB::bind_method(D_METHOD("get_map_path"), &Metin2World::get_map_path);
ClassDB::bind_method(D_METHOD("set_load_radius_tiles", "r"), &Metin2World::set_load_radius_tiles);
ClassDB::bind_method(D_METHOD("get_load_radius_tiles"), &Metin2World::get_load_radius_tiles);
ClassDB::bind_method(D_METHOD("set_focus_tile", "t"), &Metin2World::set_focus_tile);
ClassDB::bind_method(D_METHOD("get_focus_tile"), &Metin2World::get_focus_tile);
ClassDB::bind_method(D_METHOD("set_auto_load", "v"), &Metin2World::set_auto_load);
ClassDB::bind_method(D_METHOD("get_auto_load"), &Metin2World::get_auto_load);
ClassDB::bind_method(D_METHOD("set_splat_enabled", "v"), &Metin2World::set_splat_enabled);
ClassDB::bind_method(D_METHOD("get_splat_enabled"), &Metin2World::get_splat_enabled);
ClassDB::bind_method(D_METHOD("set_terrain_patches", "n"), &Metin2World::set_terrain_patches);
ClassDB::bind_method(D_METHOD("get_terrain_patches"), &Metin2World::get_terrain_patches);
ClassDB::bind_method(D_METHOD("set_objects_enabled", "v"), &Metin2World::set_objects_enabled);
ClassDB::bind_method(D_METHOD("get_objects_enabled"), &Metin2World::get_objects_enabled);
ClassDB::bind_method(D_METHOD("set_env_enabled", "v"), &Metin2World::set_env_enabled);
ClassDB::bind_method(D_METHOD("get_env_enabled"), &Metin2World::get_env_enabled);
ClassDB::bind_method(D_METHOD("set_water_enabled", "v"), &Metin2World::set_water_enabled);
ClassDB::bind_method(D_METHOD("get_water_enabled"), &Metin2World::get_water_enabled);
ClassDB::bind_method(D_METHOD("set_tree_shadows", "v"), &Metin2World::set_tree_shadows);
ClassDB::bind_method(D_METHOD("get_tree_shadows"), &Metin2World::get_tree_shadows);
ClassDB::bind_method(D_METHOD("set_static_shadows", "v"), &Metin2World::set_static_shadows);
ClassDB::bind_method(D_METHOD("get_static_shadows"), &Metin2World::get_static_shadows);
ClassDB::bind_method(D_METHOD("set_stream_budget", "v"), &Metin2World::set_stream_budget);
ClassDB::bind_method(D_METHOD("get_stream_budget"), &Metin2World::get_stream_budget);
ClassDB::bind_method(D_METHOD("load_map"), &Metin2World::load_map);
ClassDB::bind_method(D_METHOD("unload_map"), &Metin2World::unload_map);
ClassDB::bind_method(D_METHOD("set_focus_position", "gx_m", "gz_m"),
&Metin2World::set_focus_position);
ClassDB::bind_method(D_METHOD("get_perf"), &Metin2World::get_perf);
ClassDB::bind_method(D_METHOD("get_map_base_cm"), &Metin2World::get_map_base_cm);
ClassDB::bind_method(D_METHOD("get_map_size_tiles"), &Metin2World::get_map_size_tiles);
ClassDB::bind_method(D_METHOD("sample_height", "gx_m", "gz_m"), &Metin2World::sample_height);
ClassDB::bind_method(D_METHOD("sample_attribute", "gx_m", "gz_m"), &Metin2World::sample_attribute);
ClassDB::bind_method(D_METHOD("is_blocked", "gx_m", "gz_m"), &Metin2World::is_blocked);
ClassDB::bind_method(D_METHOD("load_dds", "path"), &Metin2World::load_dds);
ClassDB::bind_method(D_METHOD("chunk_dir", "tx", "ty"), &Metin2World::chunk_dir);
ClassDB::bind_method(D_METHOD("get_load_report"), &Metin2World::get_load_report);
ClassDB::bind_method(D_METHOD("bake_asset_index", "out_path"), &Metin2World::bake_asset_index);
ADD_PROPERTY(PropertyInfo(Variant::STRING, "assets_root", PROPERTY_HINT_GLOBAL_DIR),
"set_assets_root", "get_assets_root");
ADD_PROPERTY(PropertyInfo(Variant::STRING, "map_path"), "set_map_path", "get_map_path");
ADD_PROPERTY(PropertyInfo(Variant::INT, "load_radius_tiles"),
"set_load_radius_tiles", "get_load_radius_tiles");
ADD_PROPERTY(PropertyInfo(Variant::VECTOR2I, "focus_tile"),
"set_focus_tile", "get_focus_tile");
ADD_PROPERTY(PropertyInfo(Variant::BOOL, "auto_load"), "set_auto_load", "get_auto_load");
ADD_PROPERTY(PropertyInfo(Variant::BOOL, "splat_enabled"),
"set_splat_enabled", "get_splat_enabled");
ADD_PROPERTY(PropertyInfo(Variant::INT, "terrain_patches"), "set_terrain_patches", "get_terrain_patches");
ADD_PROPERTY(PropertyInfo(Variant::BOOL, "objects_enabled"),
"set_objects_enabled", "get_objects_enabled");
ADD_PROPERTY(PropertyInfo(Variant::BOOL, "env_enabled"),
"set_env_enabled", "get_env_enabled");
ADD_PROPERTY(PropertyInfo(Variant::BOOL, "water_enabled"),
"set_water_enabled", "get_water_enabled");
ADD_PROPERTY(PropertyInfo(Variant::BOOL, "tree_shadows"),
"set_tree_shadows", "get_tree_shadows");
ADD_PROPERTY(PropertyInfo(Variant::BOOL, "static_shadows"),
"set_static_shadows", "get_static_shadows");
ADD_PROPERTY(PropertyInfo(Variant::INT, "stream_budget"),
"set_stream_budget", "get_stream_budget");
}
void Metin2World::set_assets_root(const String &p) { assets_root = p; }
void Metin2World::set_map_path(const String &p) { map_path = p; }
void Metin2World::set_focus_tile(Vector2i t) { focus_tx = t.x; focus_ty = t.y; }
void Metin2World::_ready() {
set_process(true); // streaming 队列逐帧建
if (auto_load && !assets_root.is_empty())
load_map();
}
String Metin2World::map_dir() const {
String r = assets_root;
if (!r.ends_with("/"))
r += "/";
return r + map_path;
}
const Metin2World::Chunk *Metin2World::chunk_at(int tx, int ty) const {
for (auto &c : chunks)
if (c.tx == tx && c.ty == ty)
return &c;
return nullptr;
}
bool Metin2World::build_chunk(int tx, int ty) {
const std::string dir =
std::string(map_dir().utf8().get_data()) + "/" + fmt::m2coord::tile_dir(tx, ty);
auto hm = std::make_shared<fmt::HeightMap>();
std::string err;
if (!fmt::load_height_map(dir + "/height.raw", *hm, &err)) {
last_error = String("tile ") + fmt::m2coord::tile_dir(tx, ty).c_str() + ": " + err.c_str();
++chunks_failed;
return false;
}
auto am = std::make_shared<fmt::AttrMap>();
if (!fmt::load_attr_map(dir + "/attr.atr", *am, &err))
am.reset(); // 非致命:attr 缺失 -> 该区块无阻挡
fmt::TerrainMesh tmesh;
fmt::build_terrain_mesh(*hm, tx, ty, setting.height_scale, tmesh);
Ref<Material> mat;
{
Ref<StandardMaterial3D> grey;
grey.instantiate();
grey->set_albedo(Color(0.5f, 0.5f, 0.5f));
mat = grey;
}
bool splatted = false;
if (splat_ready && resolver) {
fmt::TileMap tile;
std::string e2;
if (fmt::load_tile_map(dir + "/tile.raw", tile, &e2)) {
fmt::SplatSet ss;
fmt::build_splat(tile, texture_set.runtime_count(), ss);
if (!ss.layers.empty()) {
String smpath;
String sm = String(dir.c_str()) + "/shadowmap.dds";
if (FileAccess::file_exists(sm))
smpath = sm;
Ref<ShaderMaterial> tm = build_chunk_terrain_material(
ss, texture_set, *resolver, smpath);
if (tm.is_valid()) {
mat = tm;
splatted = true;
}
}
}
}
if (splatted)
++chunks_splatted;
// 该区块的场景根 —— terrain / water / 对象 / 树都挂它下面,卸载 = free 它
Node3D *croot = memnew(Node3D);
croot->set_name(String("Chunk_") + fmt::m2coord::tile_dir(tx, ty).c_str());
add_child(croot);
// §3.5: 拆成 N×N patch,逐 patch MeshInstance —— Godot 自动逐 patch 视锥剔除,
// 远处 patch 用 visibility_range 整片剔除。terrain_patches=1 = 旧行为(整区块一 mesh)。
const int QN = fmt::TerrainMesh::QUADS_XY; // 128
const int Pn = (terrain_patches >= 1 && QN % terrain_patches == 0) ? terrain_patches : 1;
const int P = QN / Pn; // 每 patch 边上的 quad 数
const int VN = fmt::TerrainMesh::VERTS_XY; // 129
const int pw = P + 1; // 每 patch 边上的顶点数
for (int pj = 0; pj < Pn; ++pj) {
for (int pi = 0; pi < Pn; ++pi) {
const int i0 = pi * P, j0 = pj * P;
PackedVector3Array pv, pn;
PackedVector2Array pu;
pv.resize(pw * pw);
pn.resize(pw * pw);
pu.resize(pw * pw);
for (int lj = 0; lj < pw; ++lj) {
for (int li = 0; li < pw; ++li) {
const int src = (j0 + lj) * VN + (i0 + li);
const int dst = lj * pw + li;
pv[dst] = Vector3(tmesh.positions[src * 3 + 0], tmesh.positions[src * 3 + 1],
tmesh.positions[src * 3 + 2]);
pn[dst] = Vector3(tmesh.normals[src * 3 + 0], tmesh.normals[src * 3 + 1],
tmesh.normals[src * 3 + 2]);
pu[dst] = Vector2(tmesh.uvs[src * 2 + 0], tmesh.uvs[src * 2 + 1]);
}
}
PackedInt32Array pidx;
pidx.resize(P * P * 6);
int k = 0;
for (int lj = 0; lj < P; ++lj) {
for (int li = 0; li < P; ++li) {
const int TL = lj * pw + li, TR = TL + 1, BL = TL + pw, BR = BL + 1;
pidx[k++] = TL; pidx[k++] = BR; pidx[k++] = BL;
pidx[k++] = TL; pidx[k++] = TR; pidx[k++] = BR;
}
}
Array pa;
pa.resize(Mesh::ARRAY_MAX);
pa[Mesh::ARRAY_VERTEX] = pv;
pa[Mesh::ARRAY_NORMAL] = pn;
pa[Mesh::ARRAY_TEX_UV] = pu;
pa[Mesh::ARRAY_INDEX] = pidx;
Ref<ArrayMesh> pmesh;
pmesh.instantiate();
pmesh->add_surface_from_arrays(Mesh::PRIMITIVE_TRIANGLES, pa);
pmesh->surface_set_material(0, mat);
MeshInstance3D *pm = memnew(MeshInstance3D);
pm->set_name(Pn > 1 ? vformat("Terrain_%d_%d", pi, pj) : String("Terrain"));
pm->set_mesh(pmesh);
if (Pn > 1 && terrain_patch_view > 0.0f) {
// 硬剔除(无半透明淡出),避免远景地形变透明
pm->set_visibility_range_end(terrain_patch_view);
pm->set_visibility_range_fade_mode(GeometryInstance3D::VISIBILITY_RANGE_FADE_DISABLED);
}
croot->add_child(pm);
}
}
++chunks_built;
// 地形碰撞(W1 item 6):HeightMapShape3D129×129,格距 = CELL_M(缩放承载)
if (collision_enabled) {
const int N = fmt::TerrainMesh::VERTS_XY; // 129
PackedFloat32Array hd;
hd.resize(N * N);
for (int j = 0; j < N; ++j)
for (int i = 0; i < N; ++i)
hd[j * N + i] = tmesh.positions[(j * N + i) * 3 + 1]; // Godot Y
Ref<HeightMapShape3D> hs;
hs.instantiate();
hs->set_map_width(N);
hs->set_map_depth(N);
hs->set_map_data(hd);
StaticBody3D *sb = memnew(StaticBody3D);
sb->set_name("TerrainBody");
CollisionShape3D *cs = memnew(CollisionShape3D);
cs->set_shape(hs);
sb->add_child(cs);
croot->add_child(sb);
const double CM = fmt::m2coord::CELLSCALE * fmt::m2coord::CM_TO_M; // 2m
const double X0 = double(tx) * fmt::m2coord::CHUNK_CM * fmt::m2coord::CM_TO_M;
const double Z0 = double(ty) * fmt::m2coord::CHUNK_CM * fmt::m2coord::CM_TO_M;
// HeightMapShape 以中心为原点、格距 1 -> 缩放到 CM,平移到区块中心
Transform3D t;
t.basis.scale(Vector3(CM, 1, CM));
t.origin = Vector3(X0 + (N - 1) * 0.5 * CM, 0, Z0 + (N - 1) * 0.5 * CM);
sb->set_transform(t);
}
// 水面(water.wtr
if (water_enabled) {
fmt::WaterMap wm;
std::string we;
if (fmt::load_water_map(dir + "/water.wtr", wm, &we) && wm.layer_count > 0 && resolver) {
auto pieces = build_chunk_water(wm, *hm, tx, ty, setting.height_scale, *resolver);
for (auto &p : pieces) {
if (!p.mesh.is_valid())
continue;
MeshInstance3D *w = memnew(MeshInstance3D);
w->set_name("Water");
w->set_mesh(p.mesh);
croot->add_child(w);
++water_pieces;
}
}
}
Chunk ck;
ck.tx = tx;
ck.ty = ty;
ck.hm = hm;
ck.am = am;
ck.root = croot;
if (objects_enabled && registry_ok && resolver)
place_chunk_objects(tx, ty, croot, ck.objects, ck.trees);
objects_placed += ck.objects;
trees_placed += ck.trees;
chunks.push_back(std::move(ck));
return true;
}
void Metin2World::place_chunk_objects(int tx, int ty, Node3D *root, int &n_obj, int &n_tree) {
n_obj = 0;
n_tree = 0;
const std::string mdir = std::string(map_dir().utf8().get_data());
// 树按 treefile 分组 -> 每组一个 MultiMeshInstance3D
struct TreeGroup {
std::vector<Transform3D> xforms;
};
std::map<std::string, TreeGroup> tree_groups;
{
fmt::AreaData ad;
std::string e;
if (!fmt::parse_area_data_file(
mdir + "/" + fmt::m2coord::tile_dir(tx, ty) + "/areadata.txt", ad, &e))
return;
for (const fmt::AreaObject &o : ad.objects) {
const fmt::Property *p = registry.find(o.crc);
if (!p) {
++objects_skipped;
continue;
}
// areadata position = 地图全局 cm(W0 结论修正:不是区块本地!x 正/东,y 负/南)。
const double mx = o.x;
const double my = o.y;
const double mz = o.z + o.height_bias;
fmt::m2coord::Vec3 g = fmt::m2coord::position_to_godot(mx, my, mz);
if (p->type == fmt::PropertyType::Tree) {
// 原客户端 Area -> Forest::CreateInstance 在地图放置路径只设置位置;
// 不给每实例添加随机 yaw/scale。树种差异由 treefile 对应的共享 mesh 承载。
std::string tf = p->get("treefile");
if (tf.empty()) {
++objects_skipped;
continue;
}
Basis b;
tree_groups[tf].xforms.push_back(Transform3D(b, Vector3(g.x, g.y, g.z)));
continue;
}
std::string model;
if (p->type == fmt::PropertyType::Building)
model = p->get("buildingfile");
else if (p->type == fmt::PropertyType::DungeonBlock)
model = p->get("dungeonblockfile");
else {
++objects_skipped; // Effect / Ambience 不影响 R1 画面
continue;
}
if (model.empty()) {
++objects_skipped;
continue;
}
std::string rp = resolver->resolve(model, nullptr);
if (rp.empty()) {
++objects_missing_model;
continue;
}
Ref<godot::ArrayMesh> mesh = get_static_mesh(rp, *resolver, static_cache);
if (!mesh.is_valid()) {
++objects_missing_model;
continue;
}
// areadata yaw#pitch#roll 已共轭到 Godot 空间(roll = 朝向 -> 绕 Godot +Y)。
fmt::m2coord::Mat3 r = fmt::m2coord::object_basis_godot(o.yaw, o.pitch, o.roll);
Basis place_basis(
Vector3(r.m[0], r.m[3], r.m[6]),
Vector3(r.m[1], r.m[4], r.m[7]),
Vector3(r.m[2], r.m[5], r.m[8]));
Transform3D place(place_basis, Vector3(g.x, g.y, g.z));
// mesh 顶点是 gr2 原始 cm / Z-up -> 本地再套 make_convcm->m + Z-up->Y-up
Transform3D xform = place * make_conv(0.01f, false);
MeshInstance3D *mi = memnew(MeshInstance3D);
mi->set_name(String(p->name.c_str()) + "_" + String::num_uint64(o.crc));
mi->set_mesh(mesh);
mi->set_transform(xform);
// ShadowFlag: 客户端把 isShadowFlag 物体丢进动态阴影贴图(= 我们的实时投影);
// 其余物体的阴影只存在于烘焙 shadowmap.dds。__static_shadows 可强制全部实时投。
if (p->get("shadowflag") != "1" && !static_shadows)
mi->set_cast_shadows_setting(GeometryInstance3D::SHADOW_CASTING_SETTING_OFF);
// portal ids 先存进 metaR1 不做室内裁剪)
if (!o.portal_ids.empty()) {
Array pids;
for (int pid : o.portal_ids)
pids.push_back(pid);
mi->set_meta("portal_ids", pids);
}
// .mdatr 静态碰撞:未实现。有同名 .mdatr 的计数上报(SHINSOO §9-W3 第 7 项)
{
String md = String(rp.c_str()).get_basename() + ".mdatr";
if (FileAccess::file_exists(md))
++objects_mdatr_pending;
}
root->add_child(mi);
// §8.2/§8.3: 盒碰撞体(层 2 = 静态遮挡物)。相机用它做防穿 + 遮挡淡出。
// 盒尺寸/中心直接算到「米 / Y-up」,body 只带 place(旋转+平移,无 conv),
// 避免把碰撞形状放在 0.01 缩放节点下(Godot 缩放 shape 不稳)。
if (collision_enabled) {
AABB ab = mesh->get_aabb(); // gr2 原始 cm / Z-up
if (ab.size.length() > 0.001f) {
Vector3 c_cm = ab.position + ab.size * 0.5f; // 中心 cm Z-up
StaticBody3D *body = memnew(StaticBody3D);
body->set_collision_layer(2);
body->set_collision_mask(0);
body->set_transform(place); // 世界旋转 + 平移
CollisionShape3D *cs = memnew(CollisionShape3D);
Ref<BoxShape3D> box;
box.instantiate();
// Z-up cm -> Y-up m(sx, sz, sy) * 0.01
box->set_size(Vector3(ab.size.x, ab.size.z, ab.size.y) * 0.01f);
cs->set_shape(box);
cs->set_position(Vector3(c_cm.x, c_cm.z, -c_cm.y) * 0.01f);
body->add_child(cs);
body->set_meta("occ_mesh", mi); // §8.2 相机淡出用
root->add_child(body);
}
}
++n_obj;
}
}
// Tree proxy:每 treefile 一个共享 mesh + MultiMeshInstance3D。
// 当前从 .spt 嗅探真实 bark/composite atlas;几何仍等待离线 SpeedTree exporter。
for (auto &kv : tree_groups) {
if (kv.second.xforms.empty())
continue;
Ref<godot::ArrayMesh> tmesh = get_tree_proxy_mesh(kv.first, *resolver, 12.0f);
Ref<MultiMesh> mm;
mm.instantiate();
mm->set_transform_format(MultiMesh::TRANSFORM_3D);
mm->set_mesh(tmesh);
mm->set_instance_count((int)kv.second.xforms.size());
for (int i = 0; i < (int)kv.second.xforms.size(); ++i)
mm->set_instance_transform(i, kv.second.xforms[i]);
MultiMeshInstance3D *mmi = memnew(MultiMeshInstance3D);
mmi->set_name(String("Trees_") + String(kv.first.c_str()).get_file().get_basename());
mmi->set_multimesh(mm);
// §3.3: 树影已烘焙进 shadowmap.dds;proxy 几何的实时投影形状也不对 -> 默认不投。
mmi->set_cast_shadows_setting(tree_shadows ? GeometryInstance3D::SHADOW_CASTING_SETTING_ON
: GeometryInstance3D::SHADOW_CASTING_SETTING_OFF);
mmi->set_visibility_range_end(420.0f); // 远处树剔除(W8 打磨)
mmi->set_visibility_range_end_margin(60.0f); // 淡出过渡
root->add_child(mmi);
n_tree += (int)kv.second.xforms.size();
++tree_species;
}
}
bool Metin2World::load_map() {
unload_map();
std::string err;
if (!fmt::parse_map_setting_file(
std::string(map_dir().utf8().get_data()) + "/setting.txt", setting, &err)) {
last_error = String("setting.txt: ") + err.c_str();
setting_ok = false;
UtilityFunctions::push_error(String("[Metin2World] ") + last_error);
return false;
}
setting_ok = true;
// splat 前置:TextureSet + AssetResolver(整盘扫描,一次)
splat_ready = false;
if (splat_enabled) {
std::string ts_rel = setting.texture_set;
for (char &c : ts_rel) {
if (c == '\\')
c = '/';
c = (char)std::tolower((unsigned char)c);
}
const std::string root = std::string(assets_root.utf8().get_data());
std::string tse;
if (fmt::parse_texture_set_file(root + "/textureset/" + ts_rel, texture_set, &tse)) {
resolver = std::make_shared<fmt::AssetResolver>();
std::string re;
// asset_index.txt 存在就装载(PCK/移动端必走),否则扫盘(桌面开发)。
if (resolver->build_or_load(root, fmt::AssetResolver::default_priority(), &re)) {
splat_ready = true;
} else {
UtilityFunctions::push_warning(String("[Metin2World] AssetResolver: ") + re.c_str());
resolver.reset();
}
} else {
UtilityFunctions::push_warning(String("[Metin2World] TextureSet: ") + tse.c_str());
}
}
// Property CRC 注册表(一次;splat 已建 resolver
registry_ok = false;
if (objects_enabled) {
const std::string root(assets_root.utf8().get_data());
std::string re;
// resolver 已建(splat 阶段):用它的文件清单,避免再 std::filesystem 扫盘
// PCK 里扫不了)。没 resolver 时退回目录递归。
bool ok = resolver
? registry.scan_list(root, resolver->all_rel(), &re)
: registry.scan(root + "/Property", &re);
if (ok)
registry_ok = true;
else
UtilityFunctions::push_warning(String("[Metin2World] Property scan: ") + re.c_str());
}
const double t0 = Time::get_singleton()->get_ticks_usec();
if (load_radius < 0) {
// 非流式:一次全建
for (int tx = 0; tx < setting.map_size_x; ++tx)
for (int ty = 0; ty < setting.map_size_y; ++ty)
build_chunk(tx, ty);
} else {
// 流式:把 focus 半径内的区块入队,_process 逐帧建
stream_update();
// 首帧同步建完队列,避免第一帧空场景
while (!stream_queue.empty()) {
auto [tx, ty] = stream_queue.front();
stream_queue.erase(stream_queue.begin());
build_chunk(tx, ty);
}
}
// .msenv -> 光照 / 天空 / 雾 / 色调
env_ok = false;
if (env_enabled && !setting.environment.empty()) {
std::string vp = std::string("d:/ymir work/environment/") + setting.environment;
std::string rp = resolver ? resolver->resolve(vp, nullptr) : std::string();
if (rp.empty())
rp = std::string(assets_root.utf8().get_data()) +
"/ETC/ymir work/environment/" + setting.environment;
// 小写文件名
for (size_t i = rp.rfind('/') + 1; i < rp.size(); ++i)
rp[i] = (char)std::tolower((unsigned char)rp[i]);
std::string ee;
if (fmt::parse_environment_file(rp, env, &ee)) {
apply_environment(env, this);
env_ok = true;
} else {
UtilityFunctions::push_warning(String("[Metin2World] .msenv: ") + ee.c_str());
}
}
build_ms = (Time::get_singleton()->get_ticks_usec() - t0) / 1000.0;
UtilityFunctions::print(String("[Metin2World] ") + map_path + " loaded: " +
String::num_int64(chunks_built) + "/" +
String::num_int64(setting.map_size_x * setting.map_size_y) + " chunks, " +
String::num_int64(chunks_splatted) + " splatted, " +
String::num_int64(objects_placed) + " objects (" +
String::num_int64(objects_missing_model) + " missing model), " +
String::num_int64(trees_placed) + " trees/" + String::num_int64(tree_species) + " spp, " +
String::num(build_ms, 1) + " ms");
return chunks_failed == 0;
}
void Metin2World::unload_chunk(int idx) {
if (idx < 0 || idx >= (int)chunks.size())
return;
Chunk &c = chunks[idx];
objects_placed -= c.objects;
trees_placed -= c.trees;
if (chunks_built > 0)
--chunks_built;
if (c.root)
c.root->queue_free();
chunks.erase(chunks.begin() + idx);
}
void Metin2World::stream_update() {
if (!setting_ok || load_radius < 0)
return;
// 卸载半径外
for (int i = (int)chunks.size() - 1; i >= 0; --i) {
if (std::abs(chunks[i].tx - focus_tx) > load_radius ||
std::abs(chunks[i].ty - focus_ty) > load_radius)
unload_chunk(i);
}
// 入队半径内且未加载 / 未在队列的
stream_queue.clear();
for (int dx = -load_radius; dx <= load_radius; ++dx)
for (int dy = -load_radius; dy <= load_radius; ++dy) {
int tx = focus_tx + dx, ty = focus_ty + dy;
if (tx < 0 || ty < 0 || tx >= setting.map_size_x || ty >= setting.map_size_y)
continue;
if (!chunk_at(tx, ty))
stream_queue.push_back({tx, ty});
}
}
void Metin2World::set_focus_position(double gx_m, double gz_m) {
const double mx = gx_m / fmt::m2coord::CM_TO_M;
const double my_abs = gz_m / fmt::m2coord::CM_TO_M;
int tx = std::max(0, std::min(setting.map_size_x - 1, int(mx / fmt::m2coord::CHUNK_CM)));
int ty = std::max(0, std::min(setting.map_size_y - 1, int(my_abs / fmt::m2coord::CHUNK_CM)));
if (tx == focus_tx && ty == focus_ty)
return;
focus_tx = tx;
focus_ty = ty;
if (load_radius >= 0)
stream_update();
}
void Metin2World::_process(double) {
for (int n = 0; n < stream_budget && !stream_queue.empty(); ++n) {
auto [tx, ty] = stream_queue.front();
stream_queue.erase(stream_queue.begin());
build_chunk(tx, ty);
}
}
void Metin2World::unload_map() {
while (!chunks.empty())
unload_chunk((int)chunks.size() - 1);
stream_queue.clear();
if (objects_root) {
objects_root->queue_free();
objects_root = nullptr;
}
for (const char *nm : {"Sun", "WorldEnv"})
if (Node *n = get_node_or_null(NodePath(nm)))
n->queue_free();
water_pieces = 0;
env_ok = false;
env = fmt::Environment{};
static_cache = StaticMeshCache{};
registry = fmt::PropertyRegistry{};
chunks_built = chunks_failed = chunks_splatted = 0;
objects_placed = objects_skipped = objects_missing_model = 0;
trees_placed = tree_species = 0;
objects_mdatr_pending = 0;
build_ms = 0;
splat_ready = registry_ok = false;
last_error = "";
}
Vector2 Metin2World::get_map_base_cm() const {
return Vector2((float)setting.base_position_x, (float)setting.base_position_y);
}
Vector2i Metin2World::get_map_size_tiles() const {
return Vector2i(setting.map_size_x, setting.map_size_y);
}
double Metin2World::sample_height(double gx_m, double gz_m) const {
if (!setting_ok)
return 0.0;
// Godot 米 -> Metin2 全局厘米。position_to_godot: gx = mx*0.01, gz = -my*0.01
const double mx_cm = gx_m / fmt::m2coord::CM_TO_M;
const double my_abs_cm = gz_m / fmt::m2coord::CM_TO_M; // = -my; 已是正的 "南向距离"
const int tx = int(mx_cm / fmt::m2coord::CHUNK_CM);
const int ty = int(my_abs_cm / fmt::m2coord::CHUNK_CM);
const Chunk *c = chunk_at(tx, ty);
if (!c || !c->hm)
return 0.0;
const double lx = mx_cm - double(tx) * fmt::m2coord::CHUNK_CM;
const double ly = my_abs_cm - double(ty) * fmt::m2coord::CHUNK_CM;
return fmt::terrain_height_at(*c->hm, lx, ly, setting.height_scale) * fmt::m2coord::CM_TO_M;
}
Ref<godot::Image> Metin2World::load_dds(const String &path) const {
mtgodot::Image d = mtgodot::dds_from_file(path);
if (!d.ok())
return Ref<godot::Image>();
PackedByteArray b;
b.resize((int64_t)d.rgba.size());
for (size_t i = 0; i < d.rgba.size(); ++i)
b[(int64_t)i] = d.rgba[i];
return godot::Image::create_from_data(d.w, d.h, false, godot::Image::FORMAT_RGBA8, b);
}
String Metin2World::chunk_dir(int tile_x, int tile_y) const {
return map_dir() + "/" + fmt::m2coord::tile_dir(tile_x, tile_y).c_str();
}
bool Metin2World::bake_asset_index(const String &out_path) {
const std::string root(assets_root.utf8().get_data());
fmt::AssetResolver r;
std::string err;
if (!r.build(root, fmt::AssetResolver::default_priority(), &err)) {
UtilityFunctions::push_error(String("[Metin2World] bake_asset_index build: ") + err.c_str());
return false;
}
const std::string idx = r.save_index();
Ref<FileAccess> f = FileAccess::open(out_path, FileAccess::WRITE);
if (f.is_null()) {
UtilityFunctions::push_error(String("[Metin2World] bake_asset_index: cannot write ") + out_path);
return false;
}
f->store_buffer(reinterpret_cast<const uint8_t *>(idx.data()), (int64_t)idx.size());
f->close();
UtilityFunctions::print(String("[Metin2World] asset_index: ") +
String::num_int64((int64_t)r.files_indexed) + " files -> " + out_path);
return true;
}
int Metin2World::sample_attribute(double gx_m, double gz_m) const {
if (!setting_ok)
return 0;
const double mx_cm = gx_m / fmt::m2coord::CM_TO_M;
const double my_abs_cm = gz_m / fmt::m2coord::CM_TO_M;
const int tx = int(mx_cm / fmt::m2coord::CHUNK_CM);
const int ty = int(my_abs_cm / fmt::m2coord::CHUNK_CM);
const Chunk *c = chunk_at(tx, ty);
if (!c || !c->am)
return 0;
// ATTRMAP 256x256 / 区块 25600cm -> 100cm/texel
const double lx = mx_cm - double(tx) * fmt::m2coord::CHUNK_CM;
const double ly = my_abs_cm - double(ty) * fmt::m2coord::CHUNK_CM;
int ax = int(lx / 100.0), ay = int(ly / 100.0);
if (ax < 0 || ay < 0 || ax >= fmt::ATTRMAP_XY || ay >= fmt::ATTRMAP_XY)
return 0;
return c->am->data[size_t(ay) * fmt::ATTRMAP_XY + ax];
}
Dictionary Metin2World::get_load_report() const {
Dictionary d;
d["map_path"] = map_path;
d["setting_ok"] = setting_ok;
d["map_size_x"] = setting.map_size_x;
d["map_size_y"] = setting.map_size_y;
d["cell_scale"] = setting.cell_scale;
d["height_scale"] = setting.height_scale;
d["chunks_built"] = chunks_built;
d["chunks_failed"] = chunks_failed;
d["chunks_splatted"] = chunks_splatted;
d["water_pieces"] = water_pieces;
d["splat_ready"] = splat_ready;
d["registry_ok"] = registry_ok;
d["registry_crcs"] = (int)registry.by_crc.size();
d["env_ok"] = env_ok;
d["fog_level"] = env.fog.fog_level;
d["objects_placed"] = objects_placed;
d["objects_skipped"] = objects_skipped;
d["objects_missing_model"] = objects_missing_model;
d["trees_placed"] = trees_placed;
d["tree_species"] = tree_species;
d["objects_mdatr_pending"] = objects_mdatr_pending; // 有 .mdatr 但未建碰撞
d["static_meshes"] = static_cache.loaded;
d["build_ms"] = build_ms;
d["resident_chunks"] = (int)chunks.size();
d["stream_queue"] = (int)stream_queue.size();
d["load_radius_tiles"] = load_radius;
d["last_error"] = last_error;
return d;
}
Dictionary Metin2World::get_perf() const {
Dictionary d;
Performance *pf = Performance::get_singleton();
d["fps"] = pf->get_monitor(Performance::TIME_FPS);
d["process_ms"] = pf->get_monitor(Performance::TIME_PROCESS) * 1000.0;
d["frame_ms"] = pf->get_monitor(Performance::TIME_PROCESS) * 1000.0 +
pf->get_monitor(Performance::TIME_PHYSICS_PROCESS) * 1000.0;
d["draw_calls"] = pf->get_monitor(Performance::RENDER_TOTAL_DRAW_CALLS_IN_FRAME);
d["primitives"] = pf->get_monitor(Performance::RENDER_TOTAL_PRIMITIVES_IN_FRAME);
d["video_mem_mb"] = pf->get_monitor(Performance::RENDER_VIDEO_MEM_USED) / (1024.0 * 1024.0);
d["tex_mem_mb"] = pf->get_monitor(Performance::RENDER_TEXTURE_MEM_USED) / (1024.0 * 1024.0);
d["objects_3d"] = pf->get_monitor(Performance::RENDER_TOTAL_OBJECTS_IN_FRAME);
d["nodes"] = pf->get_monitor(Performance::OBJECT_NODE_COUNT);
d["resident_chunks"] = (int)chunks.size();
d["stream_queue"] = (int)stream_queue.size();
return d;
}
} // namespace mtgodot
+159
View File
@@ -0,0 +1,159 @@
#pragma once
#include <godot_cpp/classes/image.hpp>
#include <godot_cpp/classes/node3d.hpp>
#include <godot_cpp/classes/ref.hpp>
#include <godot_cpp/variant/dictionary.hpp>
#include <godot_cpp/variant/string.hpp>
#include <asset_resolver.h>
#include <environment.h>
#include <map_setting.h>
#include <property.h>
#include <terrain_files.h>
#include <texture_set.h>
#include <memory>
#include <vector>
#include "static_object.h"
namespace godot {
class MeshInstance3D;
}
namespace mtgodot {
// W1 —— 加载一张 Metin2 户外地图并渲染灰色高度地形。
// SHINSOO-WORLD-RENDERING.md §8Metin2World API 契约)/ §9-W1。
//
// Metin2World (Node3D)
// └─ Terrain_000000 (MeshInstance3D) …每区块一个
//
// 后续阶段往这里加 splat 材质(W2)、静态对象(W3)、树(W4)、环境(W5)…
class Metin2World : public godot::Node3D {
GDCLASS(Metin2World, godot::Node3D)
public:
Metin2World();
~Metin2World() override;
void _ready() override;
void _process(double delta) override;
void set_assets_root(const godot::String &p);
godot::String get_assets_root() const { return assets_root; }
void set_map_path(const godot::String &p);
godot::String get_map_path() const { return map_path; }
void set_load_radius_tiles(int r) { load_radius = r; }
int get_load_radius_tiles() const { return load_radius; }
void set_focus_tile(godot::Vector2i t);
godot::Vector2i get_focus_tile() const { return godot::Vector2i(focus_tx, focus_ty); }
void set_auto_load(bool v) { auto_load = v; }
bool get_auto_load() const { return auto_load; }
void set_splat_enabled(bool v) { splat_enabled = v; }
bool get_splat_enabled() const { return splat_enabled; }
void set_terrain_patches(int n) { terrain_patches = n; }
int get_terrain_patches() const { return terrain_patches; }
void set_objects_enabled(bool v) { objects_enabled = v; }
bool get_objects_enabled() const { return objects_enabled; }
void set_env_enabled(bool v) { env_enabled = v; }
bool get_env_enabled() const { return env_enabled; }
void set_water_enabled(bool v) { water_enabled = v; }
bool get_water_enabled() const { return water_enabled; }
// §3.3: static building/tree shadows are already baked into shadowmap.dds, so
// they don't cast realtime sun shadows by default (avoids double-darkening).
// Only shadowflag=1 buildings and dynamic actors cast realtime. Flip these on
// for a modernized look.
void set_tree_shadows(bool v) { tree_shadows = v; }
bool get_tree_shadows() const { return tree_shadows; }
void set_static_shadows(bool v) { static_shadows = v; }
bool get_static_shadows() const { return static_shadows; }
void set_stream_budget(int v) { stream_budget = v < 1 ? 1 : v; }
int get_stream_budget() const { return stream_budget; }
bool load_map();
void unload_map();
// 流式:把关注点设到某全局米坐标;load_radius_tiles >= 0 时按 3×3(radius) 装/卸区块。
void set_focus_position(double gx_m, double gz_m);
godot::Dictionary get_perf() const; // fps / frame ms / draw calls / prims / vram / 节点数
// 全局米坐标(Godot 空间)下的地表高度;地图外返回 0。
godot::Vector2 get_map_base_cm() const; // setting.txt BasePosition, in cm
godot::Vector2i get_map_size_tiles() const;
double sample_height(double gx_m, double gz_m) const;
// attr.atr 属性字节(bit0=BLOCK, bit1=WATER…);地图外返回 0。
int sample_attribute(double gx_m, double gz_m) const;
bool is_blocked(double gx_m, double gz_m) const { return (sample_attribute(gx_m, gz_m) & 1) != 0; }
godot::Dictionary get_load_report() const;
// 便捷:解一张 DDS 为 Image(HUD 小地图等用;Godot 原生不支持 .dds)。
godot::Ref<godot::Image> load_dds(const godot::String &path) const;
// map_path 下某区块目录的绝对路径(HUD 找 minimap.dds 用)。
godot::String chunk_dir(int tile_x, int tile_y) const;
// 构建期用:扫 assets_root 建 AssetResolver 索引,写到 out_pathasset_index.txt)。
// 打进 PCK 后移动端 build_or_load() 直接装载,不再 std::filesystem 扫盘。
bool bake_asset_index(const godot::String &out_path);
protected:
static void _bind_methods();
private:
godot::String assets_root;
godot::String map_path = "OutdoorA1/metin2_map_a1";
int load_radius = -1; // <0 = 全图
int focus_tx = 0, focus_ty = 0;
bool auto_load = true;
bool splat_enabled = true;
// §3.5: 每区块地形拆成 N×N patch(各自 MeshInstance),启用逐 patch 视锥剔除 +
// visibility_range 远距整片剔除。1 = 不拆(旧行为)。必须整除 128。
int terrain_patches = 4;
// patch 超过这个距离整片不画(米)。默认 3500 覆盖全图+俯视调试,实际增益来自
// 逐 patch 视锥剔除;游戏内可调低省远景地形。
float terrain_patch_view = 3500.0f;
bool objects_enabled = true;
bool env_enabled = true;
bool water_enabled = true;
bool collision_enabled = true;
bool tree_shadows = false; // trees: baked in shadowmap.dds -> no realtime cast
bool static_shadows = false; // shadowflag=0/empty buildings: same
int water_pieces = 0;
fmt::MapSetting setting;
fmt::TextureSet texture_set;
fmt::Environment env;
bool env_ok = false;
std::shared_ptr<fmt::AssetResolver> resolver;
fmt::PropertyRegistry registry;
StaticMeshCache static_cache;
bool setting_ok = false;
bool splat_ready = false;
bool registry_ok = false;
godot::String last_error;
int chunks_built = 0, chunks_failed = 0, chunks_splatted = 0;
int objects_placed = 0, objects_skipped = 0, objects_missing_model = 0;
int trees_placed = 0, tree_species = 0;
int objects_mdatr_pending = 0;
godot::Node3D *objects_root = nullptr;
double build_ms = 0;
struct Chunk {
int tx = 0, ty = 0;
std::shared_ptr<fmt::HeightMap> hm;
std::shared_ptr<fmt::AttrMap> am;
godot::Node3D *root = nullptr; // 该区块的全部场景节点(terrain + water + 对象 + 树)
int objects = 0, trees = 0;
};
std::vector<Chunk> chunks;
std::vector<std::pair<int, int>> stream_queue; // 待建区块
int stream_budget = 1; // 每帧最多建几个区块(streaming 时)
godot::String map_dir() const;
bool build_chunk(int tx, int ty);
void place_chunk_objects(int tx, int ty, godot::Node3D *root, int &n_obj, int &n_tree);
void unload_chunk(int idx);
void stream_update();
const Chunk *chunk_at(int tx, int ty) const;
};
} // namespace mtgodot
+99
View File
@@ -0,0 +1,99 @@
#pragma once
// AuthClient — drives the Metin2 auth-server exchange on top of NetStream:
// connect -> (base does KX handshake) -> GC_PHASE(PHASE_AUTH)
// -> send CG_LOGIN3{id, pwd} -> GC_AUTH_SUCCESS{login_key} | GC_LOGIN_FAILURE
// On success `login_key()` is the ticket to hand the game server via CG_LOGIN2.
#include "net_stream.h"
#include <cstring>
#include <string>
namespace mtnet {
class AuthClient : public NetStream {
public:
AuthClient(std::string id, std::string pw)
: m_id(std::move(id)), m_pw(std::move(pw)) {}
bool done() const { return m_done; }
bool success() const { return m_success; }
uint32_t login_key() const { return m_login_key; }
const std::string &fail_reason() const { return m_fail_reason; }
bool handshaked() const { return cipher_active(); }
bool sent_login() const { return m_sent_login; }
protected:
void on_phase(uint8_t phase) override {
if (phase == PHASE_AUTH) {
CGLogin3 p{};
p.header = CG_LOGIN3;
p.length = sizeof(p);
std::strncpy(p.name, m_id.c_str(), sizeof(p.name) - 1);
std::strncpy(p.pwd, m_pw.c_str(), sizeof(p.pwd) - 1);
send_packet(&p, sizeof(p));
m_sent_login = true;
} else if (phase == PHASE_CLOSE) {
m_done = true;
}
}
bool on_packet(uint16_t header, uint16_t len) override {
if (header == GC_AUTH_SUCCESS) {
GCAuthSuccess p{};
if (!recv_bytes(&p, sizeof(p))) {
return false;
}
m_success = (p.result != 0);
m_login_key = p.login_key;
if (!m_success) {
m_fail_reason = "AUTH_SUCCESS result=0";
}
m_done = true;
return true;
}
if (header == GC_LOGIN_FAILURE) {
// body is a short reason string in this fork; read + stringify
char buf[128] = {0};
DynHeader dh{};
peek_bytes(&dh, sizeof(dh));
uint16_t body = len > sizeof(DynHeader) ? len - (uint16_t)sizeof(DynHeader) : 0;
if (body > sizeof(buf) - 1) {
body = sizeof(buf) - 1;
}
// consume the whole packet
uint8_t discard[512];
if (len <= sizeof(discard)) {
recv_bytes(discard, len);
if (body > 0) {
std::memcpy(buf, discard + sizeof(DynHeader), body);
}
} else {
drop_recv();
}
m_fail_reason = std::string("LOGIN_FAILURE ") + buf;
m_done = true;
return true;
}
// Unknown auth-phase packet: log its header, consume, keep going.
uint8_t discard[1024];
if (len <= sizeof(discard)) {
recv_bytes(discard, len);
} else {
drop_recv();
}
return true;
}
void on_disconnect() override { m_done = true; }
private:
std::string m_id, m_pw;
bool m_done = false;
bool m_success = false;
bool m_sent_login = false;
uint32_t m_login_key = 0;
std::string m_fail_reason;
};
} // namespace mtnet
+78
View File
@@ -0,0 +1,78 @@
#pragma once
// Minimal growable byte buffer with a read cursor — the recv/send staging area
// for NetStream. Mirrors what the client's ByteBuffer does: append at the write
// end, consume from the read end, compact when the read cursor drifts.
#include <cstdint>
#include <cstring>
#include <vector>
namespace mtnet {
class ByteBuffer {
public:
void clear() {
m_buf.clear();
m_rpos = 0;
}
size_t readable() const { return m_buf.size() - m_rpos; }
bool has(size_t n) const { return readable() >= n; }
const uint8_t *read_ptr() const { return m_buf.data() + m_rpos; }
// copy without consuming
bool peek(void *dst, size_t n) const {
if (readable() < n) {
return false;
}
std::memcpy(dst, m_buf.data() + m_rpos, n);
return true;
}
// advance the read cursor
void discard(size_t n) {
m_rpos += n;
if (m_rpos > m_buf.size()) {
m_rpos = m_buf.size();
}
if (m_rpos == m_buf.size()) {
m_buf.clear();
m_rpos = 0;
} else if (m_rpos > (1u << 16) && m_rpos * 2 > m_buf.size()) {
m_buf.erase(m_buf.begin(), m_buf.begin() + static_cast<std::ptrdiff_t>(m_rpos));
m_rpos = 0;
}
}
bool read(void *dst, size_t n) {
if (!peek(dst, n)) {
return false;
}
discard(n);
return true;
}
void write(const void *src, size_t n) {
const auto *p = static_cast<const uint8_t *>(src);
m_buf.insert(m_buf.end(), p, p + n);
}
// raw append region (used by recv()): reserve `n`, get a pointer, then commit
uint8_t *reserve_write(size_t n) {
m_buf.resize(m_buf.size() + n);
return m_buf.data() + m_buf.size() - n;
}
void commit_write(size_t n, size_t reserved) {
if (n < reserved) {
m_buf.resize(m_buf.size() - (reserved - n));
}
}
// mutable view of unread bytes (for decrypt-in-place of already-buffered data)
uint8_t *mutable_unread() { return m_buf.data() + m_rpos; }
private:
std::vector<uint8_t> m_buf;
size_t m_rpos = 0;
};
} // namespace mtnet
File diff suppressed because it is too large Load Diff
+925
View File
@@ -0,0 +1,925 @@
#pragma once
// EntityStore — headless model of the networked world: turns game-phase packets
// (GC_MAIN_CHARACTER / GC_CHARACTER_ADD[2] / GC_CHARACTER_DEL / GC_MOVE /
// GC_CHAT / GC_CHARACTER_UPDATE / GC_FLY_TARGETING) into entity state +
// interpolated positions.
// No Godot / engine dependency (unit-testable). A bridge layer polls
// drain_changes() / drain_chat() and mirrors entities into Metin2World.
//
// Positions are Metin2 cm (server space). func mirrors CInstanceBase::FUNC_*.
#include "wire.h"
#include <cstdint>
#include <string>
#include <unordered_map>
#include <vector>
namespace mtnet {
enum : uint8_t {
FUNC_WAIT = 0,
FUNC_MOVE = 1,
FUNC_ATTACK = 2,
FUNC_COMBO = 3,
FUNC_MOB_SKILL = 4,
FUNC_EMOTION = 5,
FUNC_SKILL = 0x80,
};
struct Entity {
uint32_t vid = 0;
uint16_t race = 0;
uint8_t ch_type = 0; // CHRTYPE: 0 PC, 1 NPC, 2 MONSTER, 3 STONE, 4 WARP, ...
std::string name;
uint16_t parts[CHR_EQUIPPART_NUM] = {0, 0, 0, 0};
bool is_main = false;
float x = 0, y = 0, z = 0; // current (interpolated) position, cm
float angle = 0;
uint8_t func = FUNC_WAIT;
uint8_t position = 0;
uint8_t walk_mode = 0; // 0 walk, 1 run (server's WALKMODE_*)
uint32_t fly_target_vid = 0;
int32_t fly_target_x = 0, fly_target_y = 0;
bool fly_target_set = false;
uint16_t moving_speed = 0;
bool moving = false;
float sx = 0, sy = 0, tx = 0, ty = 0;
uint32_t move_start_ms = 0;
uint32_t move_dur_ms = 0;
// combat / status (0 = unknown until the server sends it)
uint8_t attack_speed = 0; // GC_CHARACTER_ADD[2]/UPDATE bAttackSpeed (x100)
int32_t hp = 0, max_hp = 0;
int32_t sp = 0, max_sp = 0;
int32_t level = 0;
bool dead = false;
bool stunned = false;
uint32_t mount_vnum = 0; // 0 = on foot (GC_MOUNT / GC_CHAR_ADD_INFO)
int32_t guild = 0;
int16_t alignment = 0;
uint8_t pk_mode = 0;
};
// --- P9 world systems ----------------------------------------------------
// GC_WARP — teleport target. same_server() -> just move the player; otherwise
// the caller must reconnect to addr:port (P10).
struct WarpCue {
int32_t x = 0, y = 0;
int32_t addr = 0;
uint16_t port = 0;
bool same_server() const { return addr == 0; }
};
// One atlas/minimap NPC entry (GC_NPC_POSITION).
struct NPCMark {
uint8_t type = 0;
uint32_t vnum = 0;
std::string name;
int32_t x = 0, y = 0;
};
// A quest/world marker (GC_TARGET_CREATE/UPDATE/DELETE).
struct WorldMarker {
int32_t id = 0;
std::string name;
uint32_t vid = 0;
uint8_t type = 0; // CREATE_TARGET_TYPE_*
int32_t x = 0, y = 0;
};
// One floating damage number (GC_DAMAGE_INFO).
struct DamageEvent {
uint32_t vid = 0;
uint8_t flag = 0; // DAMAGE_* bits
int32_t amount = 0;
};
// A one-shot combat/emote motion (GC_MOTION).
struct MotionEvent {
uint32_t vid = 0;
uint32_t victim_vid = 0;
uint16_t motion = 0;
};
// A mining animation broadcast (GC_DIG_MOTION).
struct DigMotionEvent {
uint32_t vid = 0;
uint32_t target_vid = 0;
uint8_t count = 0;
};
struct FishingEvent {
uint8_t subheader = 0;
uint32_t info = 0;
uint8_t dir = 0;
};
struct DungeonEvent {
uint8_t subheader = 0;
int32_t x = 0;
int32_t y = 0;
bool has_destination = false;
};
struct LandArea {
uint32_t id = 0;
int32_t x = 0, y = 0;
int32_t width = 0, height = 0;
uint32_t guild_id = 0;
};
struct Observer {
uint32_t vid = 0;
int32_t x = 0, y = 0; // server centimetres
};
struct ObserverEvent {
enum Kind { Add, Remove, Move } kind = Add;
uint32_t vid = 0;
int32_t x = 0, y = 0;
};
// A buff/debuff on the local player (GC_AFFECT_ADD/REMOVE).
struct Affect {
uint32_t type = 0;
uint8_t point_idx = 0;
int32_t value = 0;
uint32_t flag = 0;
int32_t duration = 0;
};
struct AffectChange {
uint32_t type = 0;
bool added = false;
};
// A one-shot special/specific effect to play on an entity (GC_SPECIAL/SPECIFIC_EFFECT).
struct EffectCue {
uint32_t vid = 0;
int32_t special = -1; // >=0 for GC_SPECIAL_EFFECT (built-in id)
std::string file; // set for GC_SPECIFIC_EFFECT (.mse path)
};
// A projectile (GC_CREATE_FLY): type, from vid, to vid.
struct FlyCue {
uint8_t type = 0;
uint32_t start_vid = 0;
uint32_t end_vid = 0;
};
// A server broadcast that sets or appends a shooter's fly target.
struct FlyTargetCue {
uint32_t shooter_vid = 0;
uint32_t target_vid = 0;
int32_t x = 0;
int32_t y = 0;
bool append = false;
};
// An NPC dialog to render (GC_SCRIPT): skin + raw EventManager script text.
struct ScriptCue {
uint8_t skin = 0;
std::string text;
};
// A yes/no prompt (GC_QUEST_CONFIRM).
struct ConfirmCue {
std::string msg;
int32_t timeout = 0;
uint32_t request_pid = 0;
};
// One quest-log entry (GC_QUEST_INFO, flag-driven).
struct QuestInfo {
uint16_t index = 0;
uint8_t flag = 0;
bool begin = false;
std::string title;
std::string clock_name;
int32_t clock_value = 0;
std::string counter_name;
int32_t counter_value = 0;
std::string icon;
};
// One item in a slot (inventory / equipment). vnum 0 = empty.
struct Item {
uint32_t vnum = 0;
uint8_t count = 0;
uint32_t flags = 0;
uint32_t anti_flags = 0;
int32_t sockets[ITEM_SOCKET_SLOT_MAX_NUM] = {0, 0, 0};
ItemAttr attrs[ITEM_ATTRIBUTE_SLOT_MAX_NUM] = {};
bool empty() const { return vnum == 0; }
};
// Snapshot supplied by GC_VIEW_EQUIP when inspecting another character.
struct ViewedEquipment {
uint32_t vid = 0;
Item items[VIEW_EQUIP_WEAR_MAX_NUM] = {};
};
// --- P8 party (GC_PARTY_*) --------------------------------------------------
struct PartyMember {
uint32_t pid = 0;
uint32_t vid = 0; // 0 until GC_PARTY_LINK
std::string name;
uint8_t state = 0; // bit0 = leader (this fork's server)
uint8_t hp_pct = 0; // 0..100
int16_t affects[PARTY_AFFECT_SLOT_MAX_NUM] = {0};
bool leader() const { return (state & 1) != 0; }
};
// --- P8 messenger friend list (GC_MESSENGER) ------------------------------
struct Friend {
std::string name;
bool online = false;
};
// --- P8 NPC shop (GC_SHOP) ----------------------------------------------------
struct ShopEntry {
uint32_t vnum = 0;
uint32_t price = 0;
uint8_t count = 0;
uint8_t pos = 0; // slot index within its tab (0..SHOP_HOST_ITEM_MAX_NUM-1)
};
// One shelf/tab of a SHOP_GC_START_EX shop. A plain SHOP_GC_START shop is
// modelled as a single unnamed tab.
struct ShopTab {
std::string name;
uint8_t coin_type = 0;
std::vector<ShopEntry> items;
};
// --- P8 exchange / trade (GC_EXCHANGE) -------------------------------------
struct ExchangeSlot {
uint32_t vnum = 0;
uint8_t count = 0;
};
struct ExchangeState {
bool active = false;
uint32_t partner_vid = 0;
ExchangeSlot self_items[12] = {};
ExchangeSlot peer_items[12] = {};
int64_t self_gold = 0;
int64_t peer_gold = 0;
bool self_accept = false;
bool peer_accept = false;
};
// --- guild (GC_GUILD) --------------------------------------------------------
struct GuildMember {
uint32_t pid = 0;
uint8_t grade = 0;
bool is_general = false;
uint8_t job = 0;
uint8_t level = 0;
uint32_t offer = 0;
std::string name;
};
struct GuildGrade {
std::string name;
uint8_t auth = 0;
};
struct GuildState {
bool in_guild = false;
uint32_t id = 0;
std::string name;
uint8_t level = 0;
uint32_t exp = 0;
uint32_t gold = 0;
uint16_t member_count = 0;
uint16_t max_member_count = 0;
uint32_t master_pid = 0;
bool has_land = false;
};
// GUILD_GC_SKILL_INFO — the guild-skill page.
struct GuildSkillState {
bool valid = false;
uint8_t skill_point = 0;
uint8_t levels[GUILD_SKILL_MAX_NUM] = {};
uint16_t guild_point = 0;
uint16_t max_guild_point = 0;
};
// GUILD_GC_WAR — our current guild-war status against one opponent.
struct GuildWarStatus {
uint32_t opp_guild_id = 0;
uint8_t type = 0;
uint8_t state = GUILD_WAR_NONE;
};
// GUILD_GC_WAR_POINT — a scoreboard delta.
struct GuildWarScore {
uint32_t gain_guild_id = 0;
uint32_t opp_guild_id = 0;
int32_t point = 0;
};
// --- dragon-soul refine (GC_DRAGON_SOUL_REFINE) --------------------------
struct DragonSoulCue {
uint8_t sub_type = 0; // DS_SUB_* (OPEN / REFINE_SUCCEED / REFINE_FAIL_*)
uint8_t window = 0; // affected item position (success/fail)
uint16_t cell = 0;
};
// --- refine (GC_REFINE_INFORMATION) ----------------------------------------
struct RefineCue {
uint8_t type = 0;
uint8_t pos = 0; // inventory cell of the item
uint32_t src_vnum = 0;
uint32_t result_vnum = 0;
int32_t cost = 0;
int32_t prob = 0; // success %
struct Mat {
uint32_t vnum = 0;
int32_t count = 0;
} materials[5] = {};
uint8_t material_count = 0;
};
// An item lying in the world (GC_ITEM_GROUND_ADD).
struct GroundItem {
uint32_t vid = 0;
uint32_t vnum = 0;
float x = 0, y = 0, z = 0; // server cm
std::string owner;
};
// One inventory/equipment slot changed (window = WINDOW_INVENTORY / _EQUIPMENT).
struct InvChange {
uint8_t window = 0;
uint16_t cell = 0;
};
// A ground item appeared / vanished.
struct GroundChange {
uint32_t vid = 0;
bool added = false;
};
// "You picked up N x <vnum>" (GC_ITEM_GET) or "someone used <vnum>" (GC_ITEM_USE).
struct ItemEvent {
enum Kind { Get, Use };
Kind kind = Get;
uint32_t vnum = 0;
uint8_t count = 0;
std::string from; // GC_ITEM_GET only
};
struct PvpRelation {
uint32_t src_vid = 0;
uint32_t dst_vid = 0;
uint8_t mode = 0;
};
struct LoverInfo {
std::string name;
uint8_t love_point = 0;
bool valid = false;
};
// The local player's full stat array (GC_PLAYER_POINTS), indexed by EPointTypes.
struct PlayerPoints {
int32_t v[256] = {0};
int32_t hp() const { return v[POINT_HP]; }
int32_t max_hp() const { return v[POINT_MAX_HP]; }
int32_t sp() const { return v[POINT_SP]; }
int32_t max_sp() const { return v[POINT_MAX_SP]; }
int32_t level() const { return v[POINT_LEVEL]; }
int32_t exp() const { return v[POINT_EXP]; }
int32_t next_exp() const { return v[POINT_NEXT_EXP]; }
int32_t gold() const { return v[POINT_GOLD]; }
int32_t energy() const { return v[POINT_ENERGY]; }
int32_t energy_end_time() const { return v[POINT_ENERGY_END_TIME]; }
};
class EntityStore {
public:
enum class ChangeKind { Spawn, Despawn, Move, MainSet, Info };
struct Change {
ChangeKind kind;
uint32_t vid;
};
struct ChatMsg {
uint8_t type = 0; // EChatType; CHAT_TYPE_WHISPER for whispers
uint32_t vid = 0; // speaker vid (0 for whisper / system)
std::string text;
std::string from; // whisper sender name (empty otherwise)
uint8_t sub = 0; // whisper: WHISPER_TYPE_*
};
void set_now(uint32_t now_ms) { m_now = now_ms; }
// Feed one complete game-phase packet. `body` points at the packet start
// (header/length included); `len` == that length. Unknown headers ignored.
void apply(uint16_t header, const void *body, uint16_t len);
// Advance interpolation of moving entities to m_now.
void tick();
const Entity *get(uint32_t vid) const;
uint32_t main_vid() const { return m_main_vid; }
std::vector<uint32_t> vids() const;
size_t size() const { return m_ents.size(); }
// local player's full stat block; valid once GC_PLAYER_POINTS has arrived.
const PlayerPoints &points() const { return m_points; }
// local player's skill levels (index = skill id, 0..SKILL_MAX_NUM-1).
uint8_t skill_level(int id) const {
return (id >= 0 && id < SKILL_MAX_NUM) ? m_skills[id] : 0;
}
// 0 normal / 1 master / 2 grand master / 3 perfect master (GC_SKILL_LEVEL_NEW).
uint8_t skill_master(int id) const {
return (id >= 0 && id < SKILL_MAX_NUM) ? m_skill_master[id] : 0;
}
uint8_t skill_group() const { return m_skill_group; }
bool skill_group_dirty() {
bool d = m_skill_group_dirty;
m_skill_group_dirty = false;
return d;
}
bool skills_dirty() { bool d = m_skills_dirty; m_skills_dirty = false; return d; }
// local player's quickslots (GC_QUICKSLOT_*), 0..QUICKSLOT_MAX_NUM-1.
QuickSlot quickslot(int pos) const {
return (pos >= 0 && pos < QUICKSLOT_MAX_NUM) ? m_quickslots[pos] : QuickSlot{};
}
bool quickslots_dirty() { bool d = m_quickslots_dirty; m_quickslots_dirty = false; return d; }
std::vector<int> drain_cooldown_ends() {
auto v = std::move(m_cooldown_ends);
m_cooldown_ends.clear();
return v;
}
std::vector<FlyCue> drain_fly_cues() {
auto v = std::move(m_fly_cues);
m_fly_cues.clear();
return v;
}
std::vector<FlyTargetCue> drain_fly_target_cues() {
auto v = std::move(m_fly_target_cues);
m_fly_target_cues.clear();
return v;
}
std::vector<ScriptCue> drain_scripts() {
auto v = std::move(m_scripts);
m_scripts.clear();
return v;
}
std::vector<ConfirmCue> drain_confirms() {
auto v = std::move(m_confirms);
m_confirms.clear();
return v;
}
// vids of quest-log entries changed since last drain.
std::vector<uint16_t> drain_quest_changes() {
auto v = std::move(m_quest_changes);
m_quest_changes.clear();
return v;
}
const QuestInfo *quest(uint16_t index) const {
auto it = m_quests.find(index);
return it == m_quests.end() ? nullptr : &it->second;
}
std::vector<uint16_t> quest_indices() const {
std::vector<uint16_t> v;
v.reserve(m_quests.size());
for (auto &kv : m_quests) {
v.push_back(kv.first);
}
return v;
}
// currently-selected target's HP percent (GC_TARGET_INFO); 0 vid = none.
uint32_t target_vid() const { return m_target_vid; }
uint8_t target_hp_pct() const { return m_target_hp_pct; }
// --- P8 party ---
bool in_party() const { return !m_party.empty(); }
std::vector<uint32_t> party_pids() const {
std::vector<uint32_t> v;
v.reserve(m_party.size());
for (auto &kv : m_party) {
v.push_back(kv.first);
}
return v;
}
const PartyMember *party_member(uint32_t pid) const {
auto it = m_party.find(pid);
return it == m_party.end() ? nullptr : &it->second;
}
uint8_t party_distribute_mode() const { return m_party_mode; }
bool party_dirty() { bool d = m_party_dirty; m_party_dirty = false; return d; }
std::vector<uint32_t> drain_party_invites() {
auto v = std::move(m_party_invites);
m_party_invites.clear();
return v;
}
// --- P8 messenger ---
std::vector<Friend> friends() const {
std::vector<Friend> v;
v.reserve(m_friends.size());
for (auto &kv : m_friends) {
v.push_back(kv.second);
}
return v;
}
bool friends_dirty() { bool d = m_friends_dirty; m_friends_dirty = false; return d; }
// --- P8 NPC shop ---
bool shop_open() const { return m_shop_open; }
uint32_t shop_vid() const { return m_shop_vid; }
const std::vector<ShopEntry> &shop_items() const { return m_shop_items; }
// SHOP_GC_START_EX shelves; empty for a plain SHOP_GC_START shop (use shop_items()).
const std::vector<ShopTab> &shop_tabs() const { return m_shop_tabs; }
bool shop_dirty() { bool d = m_shop_dirty; m_shop_dirty = false; return d; }
std::vector<std::string> drain_shop_errors() {
auto v = std::move(m_shop_errors);
m_shop_errors.clear();
return v;
}
// --- P8 exchange ---
const ExchangeState &exchange() const { return m_exchange; }
bool exchange_dirty() { bool d = m_exchange_dirty; m_exchange_dirty = false; return d; }
// --- P8 safebox ---
bool safebox_open() const { return m_safebox_open; }
int safebox_size() const { return m_safebox_size; }
int64_t safebox_gold() const { return m_safebox_gold; }
const Item &safebox_slot(int cell) const;
bool safebox_dirty() { bool d = m_safebox_dirty; m_safebox_dirty = false; return d; }
// --- item-mall (창고몰) ---
bool mall_open() const { return m_mall_open; }
int mall_size() const { return m_mall_size; }
const Item &mall_slot(int cell) const;
bool mall_dirty() { bool d = m_mall_dirty; m_mall_dirty = false; return d; }
// --- cube (제작) ---
struct CubeResultEntry {
uint32_t vnum = 0;
int count = 0;
};
struct CubeMaterialSlot {
uint32_t vnum = 0;
int count = 0;
};
struct CubeRecipe { // one craftable output + its materials
uint32_t result_vnum = 0;
int result_count = 0;
int64_t gold = 0;
std::vector<std::vector<CubeMaterialSlot>> material_groups; // any-of groups
};
struct CubeState {
bool open = false;
uint32_t npc_vnum = 0;
int64_t need_gold = 0; // gold the current pending craft needs
uint32_t need_item_vnum = 0; // last "cube info" hint
int need_item_count = 0;
std::vector<CubeResultEntry> results; // r_list: what this NPC can make
std::vector<CubeRecipe> recipes; // m_info: materials per result
};
enum class CubeEvent { Opened, Closed, InfoChanged, Success, Fail };
const CubeState &cube() const { return m_cube; }
std::vector<CubeEvent> drain_cube_events() {
auto v = std::move(m_cube_events);
m_cube_events.clear();
return v;
}
// last Success payload (valid right after a CubeEvent::Success is drained)
CubeResultEntry cube_last_success() const { return m_cube_last_success; }
// --- guild ---
const GuildState &guild() const { return m_guild; }
std::vector<GuildMember> guild_members() const {
std::vector<GuildMember> v;
v.reserve(m_guild_members.size());
for (auto &kv : m_guild_members) {
v.push_back(kv.second);
}
return v;
}
const GuildGrade &guild_grade(int i) const {
static const GuildGrade kEmpty;
return (i >= 0 && i < 16) ? m_guild_grades[i] : kEmpty;
}
bool guild_dirty() { bool d = m_guild_dirty; m_guild_dirty = false; return d; }
// --- guild war / guild skill ---
const GuildSkillState &guild_skill() const { return m_guild_skill; }
bool guild_skill_dirty() { bool d = m_guild_skill_dirty; m_guild_skill_dirty = false; return d; }
const GuildWarStatus &guild_war() const { return m_guild_war; }
// active GvG pairs (src,dst); order not significant.
const std::vector<GuildWarPair> &guild_wars() const { return m_guild_wars; }
bool guild_war_dirty() { bool d = m_guild_war_dirty; m_guild_war_dirty = false; return d; }
std::string guild_name(uint32_t id) const {
auto it = m_guild_names.find(id);
return it == m_guild_names.end() ? std::string() : it->second;
}
// one-shot WAR state transitions (declare/accept/start/end) for toasts.
std::vector<GuildWarStatus> drain_guild_war_events() {
auto v = std::move(m_guild_war_events);
m_guild_war_events.clear();
return v;
}
std::vector<GuildWarScore> drain_guild_war_scores() {
auto v = std::move(m_guild_war_scores);
m_guild_war_scores.clear();
return v;
}
// --- refine ---
std::vector<RefineCue> drain_refine_cues() {
auto v = std::move(m_refine_cues);
m_refine_cues.clear();
return v;
}
std::vector<DragonSoulCue> drain_ds_cues() {
auto v = std::move(m_ds_cues);
m_ds_cues.clear();
return v;
}
const Item &dragon_soul_slot(int cell) const;
// --- P9 world systems ---
std::vector<WarpCue> drain_warps() {
auto v = std::move(m_warps);
m_warps.clear();
return v;
}
// server wall-clock (unix seconds) as of the last GC_TIME; add the elapsed
// real time yourself for a running clock.
int64_t server_time() const { return m_server_time; }
bool take_time_dirty() { bool d = m_time_dirty; m_time_dirty = false; return d; }
int channel() const { return m_channel; }
bool take_channel_dirty() { bool d = m_channel_dirty; m_channel_dirty = false; return d; }
const std::vector<NPCMark> &npc_marks() const { return m_npc_marks; }
bool take_npc_marks_dirty() { bool d = m_npc_marks_dirty; m_npc_marks_dirty = false; return d; }
std::vector<WorldMarker> markers() const {
std::vector<WorldMarker> v;
v.reserve(m_markers.size());
for (auto &kv : m_markers) {
v.push_back(kv.second);
}
return v;
}
bool take_markers_dirty() { bool d = m_markers_dirty; m_markers_dirty = false; return d; }
std::vector<uint32_t> drain_mount_changes() {
auto v = std::move(m_mount_changes);
m_mount_changes.clear();
return v;
}
// items: normal inventory, 24 wear positions, and the independent 4x4 belt inventory.
const Item &inv_slot(int cell) const;
const Item &equip_slot(int wear) const;
const Item &belt_slot(int cell) const;
const Item &item_slot(uint8_t window, int cell) const;
const ViewedEquipment *viewed_equipment(uint32_t vid) const;
const GroundItem *ground(uint32_t vid) const;
std::vector<uint32_t> ground_vids() const;
std::vector<InvChange> drain_inv() {
auto v = std::move(m_inv_changes);
m_inv_changes.clear();
return v;
}
std::vector<GroundChange> drain_ground() {
auto v = std::move(m_ground_changes);
m_ground_changes.clear();
return v;
}
std::vector<uint32_t> drain_view_equipment_changes() {
auto v = std::move(m_view_equipment_changes);
m_view_equipment_changes.clear();
return v;
}
std::vector<ItemEvent> drain_item_events() {
auto v = std::move(m_item_events);
m_item_events.clear();
return v;
}
std::vector<PvpRelation> drain_pvp_changes() {
auto v = std::move(m_pvp_changes);
m_pvp_changes.clear();
return v;
}
std::vector<PvpRelation> pvp_relations() const;
bool take_duel_started() {
bool started = m_duel_started;
m_duel_started = false;
return started;
}
const LoverInfo &lover() const { return m_lover; }
bool take_lover_dirty() {
bool dirty = m_lover_dirty;
m_lover_dirty = false;
return dirty;
}
std::vector<Change> drain_changes() {
auto v = std::move(m_changes);
m_changes.clear();
return v;
}
std::vector<ChatMsg> drain_chat() {
auto v = std::move(m_chat);
m_chat.clear();
return v;
}
// vids whose hp/sp/level/dead/stunned changed since last drain (deduped).
std::vector<uint32_t> drain_vitals() {
auto v = std::move(m_vitals);
m_vitals.clear();
return v;
}
std::vector<DamageEvent> drain_damage() {
auto v = std::move(m_damage);
m_damage.clear();
return v;
}
std::vector<MotionEvent> drain_motions() {
auto v = std::move(m_motions);
m_motions.clear();
return v;
}
std::vector<DigMotionEvent> drain_dig_motions() {
auto v = std::move(m_dig_motions);
m_dig_motions.clear();
return v;
}
std::vector<FishingEvent> drain_fishing_events() {
auto v = std::move(m_fishing_events);
m_fishing_events.clear();
return v;
}
std::vector<DungeonEvent> drain_dungeon_events() {
auto v = std::move(m_dungeon_events);
m_dungeon_events.clear();
return v;
}
const std::vector<LandArea> &land_areas() const { return m_land_areas; }
bool take_land_dirty() { bool d = m_land_dirty; m_land_dirty = false; return d; }
std::vector<Observer> observers() const {
std::vector<Observer> v;
v.reserve(m_observers.size());
for (auto &kv : m_observers) v.push_back(kv.second);
return v;
}
std::vector<ObserverEvent> drain_observer_events() {
auto v = std::move(m_observer_events);
m_observer_events.clear();
return v;
}
std::vector<AffectChange> drain_affects() {
auto v = std::move(m_affect_changes);
m_affect_changes.clear();
return v;
}
std::vector<EffectCue> drain_effect_cues() {
auto v = std::move(m_effect_cues);
m_effect_cues.clear();
return v;
}
std::vector<Affect> affects() const {
std::vector<Affect> v;
v.reserve(m_affects.size());
for (auto &kv : m_affects) {
v.push_back(kv.second);
}
return v;
}
// true once since GC_PLAYER_POINTS last arrived (consumes the flag).
bool take_points_dirty() {
bool d = m_points_dirty;
m_points_dirty = false;
return d;
}
// true once since GC_TARGET_INFO last arrived (consumes the flag).
bool take_target_dirty() {
bool d = m_target_dirty;
m_target_dirty = false;
return d;
}
private:
Entity &touch(uint32_t vid, bool &created);
void start_move(Entity &e, float tx, float ty, uint32_t start_ms, uint32_t dur_ms, uint8_t func);
void mark_vitals(uint32_t vid);
Item *mut_slot(uint8_t window, uint16_t cell);
std::unordered_map<uint32_t, Entity> m_ents;
uint32_t m_main_vid = 0;
uint32_t m_now = 0;
std::vector<Change> m_changes;
std::vector<ChatMsg> m_chat;
std::vector<uint32_t> m_vitals;
std::vector<DamageEvent> m_damage;
std::vector<MotionEvent> m_motions;
std::vector<DigMotionEvent> m_dig_motions;
std::vector<FishingEvent> m_fishing_events;
std::vector<DungeonEvent> m_dungeon_events;
std::vector<LandArea> m_land_areas;
bool m_land_dirty = false;
std::unordered_map<uint32_t, Observer> m_observers;
std::vector<ObserverEvent> m_observer_events;
std::unordered_map<uint32_t, Affect> m_affects;
std::vector<AffectChange> m_affect_changes;
std::vector<EffectCue> m_effect_cues;
std::vector<FlyCue> m_fly_cues;
std::vector<FlyTargetCue> m_fly_target_cues;
uint8_t m_skills[SKILL_MAX_NUM] = {0};
uint8_t m_skill_master[SKILL_MAX_NUM] = {0};
uint8_t m_skill_group = 0;
bool m_skill_group_dirty = false;
bool m_skills_dirty = false;
QuickSlot m_quickslots[QUICKSLOT_MAX_NUM] = {};
bool m_quickslots_dirty = false;
std::vector<int> m_cooldown_ends;
std::vector<ScriptCue> m_scripts;
std::vector<ConfirmCue> m_confirms;
std::unordered_map<uint16_t, QuestInfo> m_quests;
std::vector<uint16_t> m_quest_changes;
PlayerPoints m_points;
bool m_points_dirty = false;
uint32_t m_target_vid = 0;
uint8_t m_target_hp_pct = 0;
bool m_target_dirty = false;
Item m_inventory[INVENTORY_MAX_NUM];
Item m_equipment[WEAR_MAX_NUM];
Item m_belt[BELT_INVENTORY_MAX_NUM];
std::unordered_map<uint32_t, ViewedEquipment> m_view_equipment;
std::unordered_map<uint32_t, GroundItem> m_ground;
std::vector<InvChange> m_inv_changes;
std::vector<GroundChange> m_ground_changes;
std::vector<uint32_t> m_view_equipment_changes;
std::vector<ItemEvent> m_item_events;
std::unordered_map<uint64_t, PvpRelation> m_pvp;
std::vector<PvpRelation> m_pvp_changes;
bool m_duel_started = false;
LoverInfo m_lover;
bool m_lover_dirty = false;
// P8 social / shop / storage
std::unordered_map<uint32_t, PartyMember> m_party;
uint8_t m_party_mode = 0;
bool m_party_dirty = false;
std::vector<uint32_t> m_party_invites;
std::unordered_map<std::string, Friend> m_friends;
bool m_friends_dirty = false;
bool m_shop_open = false;
uint32_t m_shop_vid = 0;
std::vector<ShopEntry> m_shop_items; // == m_shop_tabs[0].items when tabs present
std::vector<ShopTab> m_shop_tabs;
bool m_shop_dirty = false;
std::vector<std::string> m_shop_errors;
ExchangeState m_exchange;
bool m_exchange_dirty = false;
Item m_safebox[SAFEBOX_MAX_NUM];
bool m_safebox_open = false;
int m_safebox_size = 0;
int64_t m_safebox_gold = 0;
bool m_safebox_dirty = false;
Item m_mall[MALL_MAX_NUM];
bool m_mall_open = false;
int m_mall_size = 0;
bool m_mall_dirty = false;
CubeState m_cube;
std::vector<CubeEvent> m_cube_events;
CubeResultEntry m_cube_last_success;
void apply_server_command(const std::string &line);
// P9 world systems
std::vector<WarpCue> m_warps;
int64_t m_server_time = 0;
bool m_time_dirty = false;
int m_channel = 0;
bool m_channel_dirty = false;
std::vector<NPCMark> m_npc_marks;
bool m_npc_marks_dirty = false;
std::unordered_map<int32_t, WorldMarker> m_markers;
bool m_markers_dirty = false;
std::vector<uint32_t> m_mount_changes;
// guild / refine
GuildState m_guild;
std::unordered_map<uint32_t, GuildMember> m_guild_members;
GuildGrade m_guild_grades[16];
bool m_guild_dirty = false;
GuildSkillState m_guild_skill;
bool m_guild_skill_dirty = false;
GuildWarStatus m_guild_war;
std::vector<GuildWarPair> m_guild_wars;
bool m_guild_war_dirty = false;
std::unordered_map<uint32_t, std::string> m_guild_names;
std::vector<GuildWarStatus> m_guild_war_events;
std::vector<GuildWarScore> m_guild_war_scores;
std::vector<RefineCue> m_refine_cues;
// dragon-soul refine
Item m_dragon_soul[DRAGON_SOUL_MAX_NUM];
std::vector<DragonSoulCue> m_ds_cues;
};
} // namespace mtnet
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+318
View File
@@ -0,0 +1,318 @@
#pragma once
// M2Client — GDExtension Node wrapping the Metin2 net client. GDScript drives it:
//
// var c = M2Client.new()
// add_child(c)
// c.phase_changed.connect(func(p): print("phase ", p))
// c.char_list.connect(func(list): c.select_character(list[0]["index"]))
// c.entered_game.connect(func(): print("in game"))
// c.connect_to_server("192.168.21.203", 11000, "192.168.21.203", 11011,
// "admin", "123456789")
//
// Orchestration: AuthClient(auth_host:auth_port) -> on auth_ok, connect
// GameClient(game_host:game_port) which does its own KX handshake, sends
// CG_LOGIN2, surfaces the character list, then select_character() drives to the
// game phase. _process() pumps whichever stream is active.
#include <godot_cpp/classes/image.hpp>
#include <godot_cpp/classes/node.hpp>
#include <godot_cpp/classes/ref.hpp>
#include <godot_cpp/variant/array.hpp>
#include <godot_cpp/variant/dictionary.hpp>
#include <godot_cpp/variant/packed_byte_array.hpp>
#include <godot_cpp/variant/string.hpp>
#include <memory>
#include <unordered_set>
#include <vector>
namespace mtnet {
class AuthClient;
class GameClient;
class MarkClient;
class MarkImageSet;
} // namespace mtnet
namespace mtgodot {
class M2Client : public godot::Node {
GDCLASS(M2Client, godot::Node)
public:
M2Client();
~M2Client() override;
void _process(double delta) override;
void _notification(int what);
// host/port for auth and game servers; account credentials.
void connect_to_server(const godot::String &auth_host, int auth_port,
const godot::String &game_host, int game_port, const godot::String &id,
const godot::String &pw);
void disconnect_from_server();
// pick a slot from the char_list payload (its "index" field).
bool select_character(int index);
// CG_CHARACTER_CREATE / CG_CHARACTER_DELETE (mirror SendCreate/DestroyCharacterPacket).
bool create_character(int slot, const godot::String &name, int job, int shape,
int con, int intel, int str, int dex);
bool delete_character(int slot, const godot::String &private_code);
// Rename a character slot with a server-side rename card.
bool change_name(int slot, const godot::String &name);
// GC_EMPIRE (0 = server still wants an empire pick before select).
int get_empire() const;
// 3 or 4 depending on which GC_LOGIN_SUCCESS the server sent.
int get_slot_count() const;
// --- in-game intents (mirror CPythonNetworkStream) ---
// rot_deg is a compass heading in degrees; wire form is rot_deg/5 like the
// original client. x/y are server cm. time is filled from the frame clock.
bool move(int func, int arg, double rot_deg, int x, int y);
// Sends CG_CHARACTER_POSITION (legacy posture/position enum, 0..255).
bool character_position(int position);
// Sends a CG_SYNC_POSITION batch. Each entry is {vid, x, y} in server cm;
// the legacy packet allows at most 16 entries.
bool sync_positions(const godot::Array &positions);
bool request_warp();
bool fishing(double rot_deg);
bool request_dungeon();
bool attack(int motion, int victim_vid);
bool set_target(int victim_vid);
bool say(int type, const godot::String &text);
bool whisper(const godot::String &to, const godot::String &text);
// cast: CG_MOVE with func = FUNC_SKILL(0x80) | (motion_idx & 0x7F).
bool cast_skill(int motion_idx, double rot_deg, int x, int y);
// Real skill intent. For a tracked target, sends CG_FLY_TARGETING before
// CG_USE_SKILL, matching the legacy client packet order.
bool use_skill(int skill_id, int target_vid);
// Ranged animation events and area-target selection use separate packets.
bool shoot(int skill_id);
bool add_fly_targeting(int target_vid, int x, int y);
// server-driven quest command: "/skillup <skill_id>".
bool skill_up(int skill_id);
int get_skill_group() const;
godot::Array get_skills() const; // [{id, level, master}] for skills the player has
godot::Array get_quickslots() const; // [{pos, type, ref}] restored quickslots
bool quickslot_add(int pos, int type, int ref);
bool quickslot_del(int pos);
bool quickslot_swap(int pos, int change_pos);
// quest / NPC
bool click_npc(int vid);
bool script_answer(int answer); // dialog choice (0..N-1) or 255 = continue
bool script_button(int idx); // quest-log button
bool script_select_item(int selection); // inventory cell/item position
bool quest_input(const godot::String &text);
bool quest_confirm(bool yes, int request_pid);
bool quest_cancel();
godot::Array get_quests() const; // [{index, title, counter_name, counter_value, ...}]
// --- P8 party ---
bool party_invite(int vid);
bool party_answer(int leader_pid, bool accept);
bool party_leave(int pid); // expel <pid>, or your own pid to leave
bool party_use_skill(int skill_index, int target_vid);
bool party_set_distribute(int mode);
bool party_set_state(int pid, int role, bool on); // CG_PARTY_SET_STATE (role = PARTY_ROLE_*)
godot::Array get_party() const; // [{pid, vid, name, leader, hp_pct, state, affects[7]}]
int get_party_distribute_mode() const;
// --- P8 messenger / friends ---
bool add_friend(const godot::String &name);
bool remove_friend(const godot::String &name);
godot::Array get_friends() const; // [{name, online}]
godot::Dictionary get_lover() const; // {valid, name, love_point}
// --- P8 NPC shop ---
bool shop_buy(int pos, int count);
bool shop_sell(int inv_cell, int count);
bool shop_close();
bool is_shop_open() const;
godot::Array get_shop_items() const; // [{pos, vnum, price, count}] — tab 0
godot::Dictionary get_shop() const; // {vid, open, tabs:[{name, coin_type, items:[...]}]}
// --- P8 exchange / trade ---
bool exchange_start(int vid);
bool exchange_add_item(int inv_window, int inv_cell, int display_pos);
bool exchange_add_gold(int gold);
bool exchange_accept();
bool exchange_cancel();
godot::Dictionary get_exchange() const; // {active, partner_vid, self_items, peer_items, ...}
// --- P8 safebox / storage ---
bool safebox_checkin(int safe_pos, int inv_window, int inv_cell);
bool safebox_checkout(int safe_pos, int inv_window, int inv_cell);
bool safebox_move(int from_cell, int to_cell, int count);
bool is_safebox_open() const;
int get_safebox_size() const;
int get_safebox_gold() const;
godot::Array get_safebox_items() const; // [{cell, vnum, count}]
// --- item-mall (창고몰) ---
bool is_mall_open() const;
int get_mall_size() const;
godot::Array get_mall_items() const; // [{cell, vnum, count}]
bool mall_checkout(int mall_pos, int inv_window, int inv_cell);
// --- private (PC) shop ---
// items: Array of {vnum, count, inv_cell, price, display_pos}
bool open_private_shop(const godot::String &sign, const godot::Array &items);
bool close_private_shop();
// --- cube (제작) ---
godot::Dictionary get_cube() const; // {open, npc_vnum, need_gold, recipes:[...]}
bool cube_make(int result_index);
bool cube_request_result_list(int npc_vnum);
bool cube_request_materials(int start_index, int count);
// --- guild ---
godot::Dictionary get_guild() const; // {in_guild, id, name, level, exp, gold, ...}
godot::Array get_guild_members() const; // [{pid, name, grade, job, level, offer, general}]
godot::Array get_guild_grades() const; // [{name, auth}] index = grade
bool guild_add_member(int vid);
bool guild_remove_member(int pid);
bool guild_offer(int amount);
bool guild_answer_make(const godot::String &name);
// --- guild war / guild skill ---
godot::Dictionary get_guild_skill() const; // {valid, skill_point, guild_point, max_guild_point, levels[12]}
godot::Array get_guild_wars() const; // [{src, dst, src_name, dst_name}] active GvG
godot::Dictionary get_guild_war() const; // {opp_guild_id, opp_name, type, state}
godot::String get_guild_name(int guild_id) const;
bool use_guild_skill(int skill_vnum, int target_vid);
bool declare_guild_war(const godot::String &guild_name); // sends "/war <name>"
// --- guild marks (会徽) ---
// Opens the side connection to `host:port` and pulls the mark images using
// the handle/random_key from login. `guild_marks_ready` fires when done.
// A port of 0 skips the query. Safe to call again on `guild_mark_updated`.
bool download_guild_marks(const godot::String &host, int port);
// Download one raw guild-symbol file via the mark side connection.
bool download_guild_symbol(const godot::String &host, int port, int guild_id);
// Raw bytes from the last completed download_guild_symbol call.
godot::PackedByteArray get_guild_symbol() const;
bool are_guild_marks_ready() const;
// {host, port} last used for a mark connection (0 port = none configured yet).
godot::Dictionary get_mark_server() const;
// Upload this guild's 16x12 mark (a godot::Image, converted/resized as needed).
// `guild_mark_uploaded(ok)` fires when the packet has left the socket.
bool upload_guild_mark(const godot::String &host, int port, int guild_id,
const godot::Ref<godot::Image> &img);
// Upload the raw bytes of a guild-symbol image file (server validates 64x128).
bool upload_guild_symbol(const godot::String &host, int port, int guild_id,
const godot::PackedByteArray &file_bytes);
// {found, img_idx, x, y, w, h} — position of a guild's 16x12 mark in its image.
godot::Dictionary get_guild_mark(int guild_id) const;
// A 16x12 RGBA8 godot::Image for a guild's mark, or an empty (null) Ref if we
// have no mark for it yet.
godot::Ref<godot::Image> get_guild_mark_image(int guild_id) const;
// --- refine / upgrade ---
bool refine(int pos, int type); // confirm refine of the item at inventory `pos`
// --- dragon soul refine ---
// mode: 0 = 升级 (upgrade), 1 = 改良 (improvement), 2 = 精炼 (refine).
// cells: inventory cells; cells[0] = the dragon soul, cells[1..] = materials (<=15 total).
bool ds_refine(int mode, const godot::Array &cells);
godot::Array get_dragon_souls() const; // [{cell, vnum, count}] non-empty DS-window slots
// --- P9 world systems ---
int get_channel() const;
int64_t get_server_time() const; // unix seconds as of last GC_TIME
godot::Array get_npc_marks() const; // [{type, vnum, name, pos}] pos = Godot metres
godot::Array get_land_areas() const; // [{id, guild_id, rect}] in server cm
godot::Array get_observers() const; // [{vid, pos}] in Godot metres
godot::Array get_world_markers() const; // [{id, name, vid, type, pos}]
// items. window: 1=inventory, 2=equipment (mtnet::WINDOW_*).
bool move_item(int from_window, int from_cell, int to_window, int to_cell, int count);
bool use_item(int window, int cell);
bool drop_item(int window, int cell, int gold);
bool drop_item_count(int window, int cell, int gold, int count);
bool use_item_to_item(int source_window, int source_cell, int target_window, int target_cell);
bool give_item(int target_vid, int window, int cell, int count);
bool pickup_item(int ground_vid);
godot::Array get_inventory() const; // non-empty slots
godot::Array get_equipment() const; // WEAR_MAX_NUM slots (may be empty)
godot::Array get_belt_inventory() const; // non-empty 4x4 belt slots
godot::Array get_view_equipment(int vid) const; // 11 legacy inspect slots (may be empty)
godot::Dictionary get_item(int window, int cell) const;
godot::Array get_ground_items() const; // {vid, vnum, pos}
godot::Array get_pvp_relations() const; // [{src_vid, dst_vid, mode}]
// --- networked world snapshot (positions already Godot-space, metres) ---
godot::Dictionary get_entity(int vid) const;
godot::Array get_entities() const;
int get_main_vid() const;
godot::Dictionary get_points() const; // local player stat block
godot::Dictionary get_target() const; // {vid, hp_percent} of selected target
godot::Array get_affects() const; // active buffs/debuffs on the local player
// App lifecycle (F5). suspend() stops pumping the socket (called
// automatically on NOTIFICATION_APPLICATION_PAUSED); resume() re-enables it.
// A mobile OS tears the TCP connection down within seconds of backgrounding,
// so the first pump after resume typically surfaces `disconnected` — call
// reconnect() to redo the login with the stored credentials.
void suspend();
void resume();
bool reconnect();
bool is_suspended() const { return suspended; }
godot::String get_stage() const { return stage_name(); }
bool is_in_game() const;
// Drain the socket + answer PING once, WITHOUT emitting the per-frame world
// signals. Call this from GDScript around any blocking work (model loads,
// map build) so the server doesn't drop us for going silent.
void net_poll();
protected:
static void _bind_methods();
private:
enum class Stage { Idle, AuthConnect, AuthWait, GameConnect, GameLogin, InGame, Failed };
Stage stage = Stage::Idle;
godot::String stage_name() const;
void set_stage(Stage s);
godot::String game_host;
int game_port = 0;
godot::String account_id;
// stored so reconnect() can redo the full auth->game flow after a resume.
godot::String cfg_auth_host, cfg_game_host, cfg_id, cfg_pw;
int cfg_auth_port = 0, cfg_game_port = 0;
bool have_cfg = false;
bool suspended = false;
std::unique_ptr<mtnet::AuthClient> auth;
std::unique_ptr<mtnet::GameClient> game;
std::unique_ptr<mtnet::MarkClient> mark;
std::unique_ptr<mtnet::MarkImageSet> mark_store; // survives after `mark` is torn down
godot::String mark_host;
int mark_port = 0;
bool mark_ready = false;
uint32_t symbol_guild_id = 0;
std::vector<uint8_t> symbol_data;
void pump_mark();
int last_auth_state = -1;
int last_game_state = -1;
int last_game_phase = -1;
bool char_list_emitted = false;
godot::Array build_char_list() const;
// CG_ENTERGAME is sent ~1.5s into PHASE_LOADING (not immediately) — sending
// it before the server finishes the spawn burst makes it drop us ~10s later.
uint64_t loading_since_ms = 0;
bool enter_game_sent = false;
bool shop_open_seen = false; // last shop_open() state we emitted a signal for
bool mall_open_seen = false;
uint32_t last_login_key = 0; // for a cross-server GC_WARP reconnect
void warp_to_game_server(const godot::String &host, int port);
std::unordered_set<uint32_t> dead_seen; // vids we've already fired entity_dead for
void pump_auth();
void pump_game();
};
} // namespace mtgodot
+266
View File
@@ -0,0 +1,266 @@
#pragma once
// MarkClient — the Metin2 guild-mark side connection on top of NetStream.
//
// Download (default): connect -> (base KX) -> on_cipher_active: CG_MARK_LOGIN
// -> GC_PHASE(PHASE_LOGIN): CG_MARK_IDXLIST -> GC_MARK_IDXLIST (guild->mark_id)
// -> per referenced image: CG_MARK_CRCLIST -> GC_MARK_BLOCK (LZO 64x48 blocks
// into MarkImageSet) -> complete().
//
// Upload (set_upload_mark / set_upload_symbol before connect): same login, then
// on PHASE_LOGIN push CG_MARK_UPLOAD (raw 16x12 mark) or CG_GUILD_SYMBOL_UPLOAD
// (head + raw file bytes) and finish. upload_done() once the send buffer drains.
//
// handle / random_key come from GC_LOGIN_SUCCESS3/4 on the game connection.
// GC_MARK_* bodies are framed by a u32 buf_size (whole-packet size), so they go
// through NetStream::on_raw() rather than the u16-length on_packet() path.
#include "mark_image.h"
#include "net_stream.h"
#include <cstring>
#include <vector>
namespace mtnet {
class MarkClient : public NetStream {
public:
enum class Mode { Download, DownloadSymbol, UploadMark, UploadSymbol };
MarkClient(uint32_t handle, uint32_t random_key)
: m_handle(handle), m_random_key(random_key) {}
// Call before connect() to switch this connection to an upload.
void set_upload_mark(uint32_t guild_id, const uint32_t px[GUILD_MARK_WIDTH * GUILD_MARK_HEIGHT]) {
m_mode = Mode::UploadMark;
m_up_gid = guild_id;
std::memcpy(m_up_mark, px, sizeof(m_up_mark));
}
void set_upload_symbol(uint32_t guild_id, std::vector<uint8_t> bytes) {
m_mode = Mode::UploadSymbol;
m_up_gid = guild_id;
m_up_symbol = std::move(bytes);
}
void set_download_symbol(uint32_t guild_id, uint32_t crc = 0, uint32_t size = 0) {
m_mode = Mode::DownloadSymbol;
m_symbol_gid = guild_id;
m_symbol_crc = crc;
m_symbol_size = size;
}
Mode mode() const { return m_mode; }
bool complete() const { return m_complete; }
bool logged_in() const { return m_login_sent; }
bool upload_sent() const { return m_upload_sent; }
// upload finished = the packet was queued AND has left our send buffer.
bool upload_done() const { return m_upload_sent && send_pending() == 0; }
const MarkImageSet &marks() const { return m_marks; }
uint32_t symbol_guild_id() const { return m_symbol_gid; }
const std::vector<uint8_t> &symbol_data() const { return m_symbol_data; }
MarkImageSet &marks() { return m_marks; }
size_t images_done() const { return m_next; }
size_t images_wanted() const { return m_needed.size(); }
protected:
void on_cipher_active() override {
CGMarkLogin p{};
p.header = CG_MARK_LOGIN;
p.length = sizeof(p);
p.handle = m_handle;
p.random_key = m_random_key;
send_packet(&p, sizeof(p));
m_login_sent = true;
}
void on_phase(uint8_t phase) override {
if (phase == PHASE_CLOSE) {
m_complete = true;
return;
}
if (phase != PHASE_LOGIN) {
return;
}
if (m_mode == Mode::UploadMark) {
send_upload_mark();
m_upload_sent = true;
m_complete = true;
return;
}
if (m_mode == Mode::UploadSymbol) {
send_upload_symbol();
m_upload_sent = true;
m_complete = true;
return;
}
if (m_mode == Mode::DownloadSymbol) {
CGSymbolCRC p{CG_SYMBOL_CRC, sizeof(CGSymbolCRC), m_symbol_gid, m_symbol_crc, m_symbol_size};
send_packet(&p, sizeof(p));
m_symbol_crc_sent = true;
return;
}
if (!m_idx_requested) {
CGMarkIDXList p{CG_MARK_IDXLIST, sizeof(CGMarkIDXList)};
send_packet(&p, sizeof(p));
m_idx_requested = true;
}
}
Raw on_raw(uint16_t header) override {
if (header == GC_MARK_IDXLIST) {
return recv_idxlist();
}
if (header == GC_MARK_BLOCK) {
return recv_block();
}
return Raw::NotHandled;
}
// GC_SYMBOL_DATA is a normal u16-length packet whose body is the raw symbol file.
// Other small packets on this stream (incl. GC_MARK_DIFF_DATA) are discarded.
bool on_packet(uint16_t header, uint16_t len) override {
if (header == GC_SYMBOL_DATA && m_mode == Mode::DownloadSymbol) {
if (len < sizeof(GCSymbolData)) {
consume_packet(len);
return true;
}
std::vector<uint8_t> buf(len);
if (!recv_bytes(buf.data(), buf.size())) {
return false;
}
GCSymbolData p{};
std::memcpy(&p, buf.data(), sizeof(p));
m_symbol_gid = p.guild_id;
m_symbol_data.assign(buf.begin() + sizeof(p), buf.end());
m_complete = true;
return true;
}
if (len <= 4096) {
uint8_t discard[4096];
recv_bytes(discard, len);
} else {
drop_recv();
}
return true;
}
void on_disconnect() override { m_complete = true; }
private:
static constexpr uint32_t kMaxBody = 8u * 1024 * 1024;
Raw recv_idxlist() {
if (recv_avail() < sizeof(GCMarkIDXList)) {
return Raw::NeedMore;
}
GCMarkIDXList head;
std::memcpy(&head, recv_ptr(), sizeof(head));
uint32_t total = head.buf_size;
if (total < sizeof(GCMarkIDXList)) {
total = (uint32_t)sizeof(GCMarkIDXList) + (uint32_t)head.count * 4;
}
if (total > kMaxBody) {
return Raw::Error;
}
if (recv_avail() < total) {
return Raw::NeedMore;
}
std::vector<uint8_t> buf(total);
recv_bytes(buf.data(), total);
parse_mark_idxlist(buf.data() + sizeof(GCMarkIDXList), total - sizeof(GCMarkIDXList),
head.count, m_marks);
m_needed = m_marks.needed_images();
m_next = 0;
if (m_needed.empty()) {
m_complete = true;
} else {
send_crclist(m_needed[0]);
}
return Raw::Consumed;
}
Raw recv_block() {
if (recv_avail() < sizeof(GCMarkBlock)) {
return Raw::NeedMore;
}
GCMarkBlock head;
std::memcpy(&head, recv_ptr(), sizeof(head));
uint32_t total = head.buf_size;
if (total < sizeof(GCMarkBlock) || total > kMaxBody) {
return Raw::Error;
}
if (recv_avail() < total) {
return Raw::NeedMore;
}
std::vector<uint8_t> buf(total);
recv_bytes(buf.data(), total);
parse_mark_block(buf.data() + sizeof(GCMarkBlock), total - sizeof(GCMarkBlock),
head.img_idx, head.count, m_marks);
++m_next;
if (m_next < m_needed.size()) {
send_crclist(m_needed[m_next]);
} else {
m_complete = true;
}
return Raw::Consumed;
}
void send_crclist(int img_idx) {
CGMarkCRCList p{};
p.header = CG_MARK_CRCLIST;
p.length = sizeof(p);
p.img_idx = (uint8_t)img_idx;
// crclist stays all-zero: "I have nothing for this image, send it whole".
send_packet(&p, sizeof(p));
}
void send_upload_mark() {
CGMarkUpload p{};
p.header = CG_MARK_UPLOAD;
p.length = sizeof(p);
p.gid = m_up_gid;
std::memcpy(p.image, m_up_mark, sizeof(p.image));
send_packet(&p, sizeof(p));
}
void send_upload_symbol() {
if (m_up_symbol.empty()) {
return;
}
std::vector<uint8_t> buf(sizeof(CGSymbolUpload) + m_up_symbol.size());
CGSymbolUpload head{};
head.header = CG_GUILD_SYMBOL_UPLOAD;
head.length = (uint16_t)buf.size();
head.handle = m_up_gid; // reference stores the guild id here
std::memcpy(buf.data(), &head, sizeof(head));
std::memcpy(buf.data() + sizeof(head), m_up_symbol.data(), m_up_symbol.size());
send_packet(buf.data(), buf.size());
}
void consume_packet(uint16_t len) {
std::vector<uint8_t> discard(len);
recv_bytes(discard.data(), discard.size());
}
uint32_t m_handle;
uint32_t m_random_key;
bool m_login_sent = false;
bool m_idx_requested = false;
bool m_complete = false;
Mode m_mode = Mode::Download;
uint32_t m_up_gid = 0;
uint32_t m_up_mark[GUILD_MARK_WIDTH * GUILD_MARK_HEIGHT] = {};
std::vector<uint8_t> m_up_symbol;
bool m_upload_sent = false;
uint32_t m_symbol_gid = 0;
uint32_t m_symbol_crc = 0;
uint32_t m_symbol_size = 0;
bool m_symbol_crc_sent = false;
std::vector<uint8_t> m_symbol_data;
MarkImageSet m_marks;
std::vector<int> m_needed;
size_t m_next = 0;
};
} // namespace mtnet
+174
View File
@@ -0,0 +1,174 @@
#include "mark_image.h"
#include <lzo/lzo1x.h>
#include <algorithm>
#include <cstring>
namespace mtnet {
namespace {
bool g_lzo_ready = false;
void ensure_lzo() {
if (!g_lzo_ready) {
lzo_init();
g_lzo_ready = true;
}
}
uint16_t rd_u16(const uint8_t *p) {
return (uint16_t)(p[0] | (p[1] << 8));
}
uint32_t rd_u32(const uint8_t *p) {
return (uint32_t)p[0] | ((uint32_t)p[1] << 8) | ((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24);
}
} // namespace
void MarkImageSet::clear() {
m_gid_mark.clear();
m_images.clear();
}
void MarkImageSet::add_mark(uint32_t guild_id, uint32_t mark_id) {
if (mark_id >= (uint32_t)(MARK_IMAGE_MAX_COUNT * MARK_PER_IMAGE)) {
return;
}
m_gid_mark[guild_id] = mark_id;
}
uint32_t MarkImageSet::mark_id(uint32_t guild_id) const {
auto it = m_gid_mark.find(guild_id);
return it == m_gid_mark.end() ? 0xFFFFFFFFu : it->second;
}
std::vector<int> MarkImageSet::needed_images() const {
std::vector<int> v;
for (const auto &kv : m_gid_mark) {
int idx = (int)(kv.second / MARK_PER_IMAGE);
bool seen = false;
for (int e : v) {
if (e == idx) {
seen = true;
break;
}
}
if (!seen) {
v.push_back(idx);
}
}
std::sort(v.begin(), v.end());
return v;
}
std::vector<uint32_t> &MarkImageSet::touch_image(int img_idx) {
auto it = m_images.find(img_idx);
if (it == m_images.end()) {
it = m_images.emplace(img_idx, std::vector<uint32_t>(MARK_IMAGE_PIXELS, 0)).first;
}
return it->second;
}
bool MarkImageSet::apply_block(int img_idx, int block_pos, const uint8_t *comp, uint32_t comp_len) {
if (img_idx < 0 || img_idx >= MARK_IMAGE_MAX_COUNT) {
return false;
}
if (block_pos < 0 || block_pos >= MARK_BLOCK_TOTAL_COUNT || comp == nullptr || comp_len == 0) {
return false;
}
ensure_lzo();
uint32_t block[MARK_BLOCK_PIXELS];
lzo_uint out_len = sizeof(block);
int r = lzo1x_decompress_safe(comp, comp_len, (uint8_t *)block, &out_len, nullptr);
if (r != LZO_E_OK || out_len != sizeof(block)) {
return false;
}
const int row_block = block_pos / MARK_BLOCK_COL_COUNT;
const int col_block = block_pos % MARK_BLOCK_COL_COUNT;
const int ox = col_block * MARK_BLOCK_WIDTH;
const int oy = row_block * MARK_BLOCK_HEIGHT;
std::vector<uint32_t> &img = touch_image(img_idx);
for (int j = 0; j < MARK_BLOCK_HEIGHT; ++j) {
uint32_t *dst = img.data() + (size_t)(oy + j) * MARK_IMAGE_WIDTH + ox;
const uint32_t *src = block + (size_t)j * MARK_BLOCK_WIDTH;
std::memcpy(dst, src, MARK_BLOCK_WIDTH * sizeof(uint32_t));
}
return true;
}
MarkRect MarkImageSet::rect_of(uint32_t guild_id) const {
MarkRect rc;
auto it = m_gid_mark.find(guild_id);
if (it == m_gid_mark.end()) {
return rc;
}
const uint32_t mid = it->second;
const int pos = (int)(mid % MARK_PER_IMAGE);
rc.found = true;
rc.img_idx = (int)(mid / MARK_PER_IMAGE);
rc.x = (pos % MARK_COL_COUNT) * GUILD_MARK_WIDTH;
rc.y = (pos / MARK_COL_COUNT) * GUILD_MARK_HEIGHT;
return rc;
}
const std::vector<uint32_t> &MarkImageSet::image(int img_idx) const {
static const std::vector<uint32_t> kEmpty;
auto it = m_images.find(img_idx);
return it == m_images.end() ? kEmpty : it->second;
}
std::vector<uint32_t> MarkImageSet::mark_pixels(uint32_t guild_id) const {
MarkRect rc = rect_of(guild_id);
if (!rc.found) {
return {};
}
const std::vector<uint32_t> &img = image(rc.img_idx);
if (img.empty()) {
return {};
}
std::vector<uint32_t> out((size_t)GUILD_MARK_WIDTH * GUILD_MARK_HEIGHT, 0);
for (int j = 0; j < GUILD_MARK_HEIGHT; ++j) {
const uint32_t *src = img.data() + (size_t)(rc.y + j) * MARK_IMAGE_WIDTH + rc.x;
std::memcpy(out.data() + (size_t)j * GUILD_MARK_WIDTH, src, GUILD_MARK_WIDTH * sizeof(uint32_t));
}
return out;
}
size_t parse_mark_idxlist(const uint8_t *body, size_t n, uint16_t count, MarkImageSet &out) {
size_t done = 0;
for (uint16_t i = 0; i < count; ++i) {
const size_t off = (size_t)i * 4;
if (off + 4 > n) {
break;
}
uint16_t gid = rd_u16(body + off);
uint16_t mid = rd_u16(body + off + 2);
out.add_mark(gid, mid);
++done;
}
return done;
}
size_t parse_mark_block(const uint8_t *body, size_t n, int img_idx, uint32_t count, MarkImageSet &out) {
size_t off = 0;
size_t applied = 0;
for (uint32_t i = 0; i < count; ++i) {
if (off + 5 > n) {
break;
}
uint8_t pos = body[off];
uint32_t comp_size = rd_u32(body + off + 1);
off += 5;
if (comp_size == 0 || off + comp_size > n) {
break;
}
if (out.apply_block(img_idx, pos, body + off, comp_size)) {
++applied;
}
off += comp_size;
}
return applied;
}
} // namespace mtnet
+77
View File
@@ -0,0 +1,77 @@
#pragma once
// MarkImageSet — the client side of the Metin2 guild-mark image store.
//
// A guild mark is a 16x12 RGBA sprite. The server keeps them packed into up to
// five 512x512 "mark images"; each image is an 8x10 grid of 64x48 blocks and
// each block is a 4x4 grid of marks. The mark server streams the blocks that
// differ from what we hold (LZO1X-compressed, 12288 bytes raw per block).
//
// Wire: GC_MARK_IDXLIST gives guild_id -> mark_id; mark_id / 1280 picks the
// image, mark_id % 1280 the position within it. GC_MARK_BLOCK carries the
// compressed pixels. See wire.h for the packet layout and MarkImage.h in the
// reference client for the geometry.
#include "wire.h"
#include <cstdint>
#include <map>
#include <vector>
namespace mtnet {
struct MarkRect {
bool found = false;
int img_idx = 0;
int x = 0, y = 0; // top-left in the 512x512 image
int w = GUILD_MARK_WIDTH;
int h = GUILD_MARK_HEIGHT;
};
class MarkImageSet {
public:
// GC_MARK_IDXLIST entry.
void add_mark(uint32_t guild_id, uint32_t mark_id);
// Which images at least one known guild references, ascending, deduplicated.
// This is the set of CG_MARK_CRCLIST requests the downloader must make.
std::vector<int> needed_images() const;
// GC_MARK_BLOCK entry: LZO1X-decompress `comp` (must expand to exactly
// MARK_BLOCK_PIXELS RGBA words) and blit it into image `img_idx` at
// `block_pos` (block_pos / 8 = row, block_pos % 8 = col). Returns false on a
// bad index or a decompression failure.
bool apply_block(int img_idx, int block_pos, const uint8_t *comp, uint32_t comp_len);
bool has_mark(uint32_t guild_id) const { return m_gid_mark.count(guild_id) != 0; }
uint32_t mark_id(uint32_t guild_id) const;
MarkRect rect_of(uint32_t guild_id) const;
// The whole 512x512 RGBA image (row-major, 0xAABBGGRR words), or empty.
const std::vector<uint32_t> &image(int img_idx) const;
// The 16x12 = 192 RGBA words for one guild's mark, or empty if unknown /
// not yet downloaded.
std::vector<uint32_t> mark_pixels(uint32_t guild_id) const;
int image_count() const { return (int)m_images.size(); }
size_t mark_count() const { return m_gid_mark.size(); }
void clear();
private:
std::vector<uint32_t> &touch_image(int img_idx);
std::map<uint32_t, uint32_t> m_gid_mark; // guild_id -> mark_id
std::map<int, std::vector<uint32_t>> m_images; // img_idx -> 512*512 RGBA
};
// Parse the body that follows the 10-byte GCMarkIDXList head: `count` pairs of
// { uint16_t guild_id; uint16_t mark_id }. Returns the number of pairs read.
size_t parse_mark_idxlist(const uint8_t *body, size_t n, uint16_t count, MarkImageSet &out);
// Parse the body that follows the 13-byte GCMarkBlock head: `count` entries of
// { uint8_t block_pos; uint32_t comp_size; uint8_t comp[comp_size] }. Returns
// the number of blocks successfully applied.
size_t parse_mark_block(const uint8_t *body, size_t n, int img_idx, uint32_t count, MarkImageSet &out);
} // namespace mtnet
+368
View File
@@ -0,0 +1,368 @@
#include "net_stream.h"
#include <arpa/inet.h>
#include <cerrno>
#include <cstdio>
#include <cstring>
#include <fcntl.h>
#include <netdb.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <sys/socket.h>
#include <unistd.h>
namespace mtnet {
namespace {
constexpr uint16_t MAX_PACKET_LENGTH = 65000;
void set_nonblocking(int fd) {
int fl = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, fl | O_NONBLOCK);
}
} // namespace
NetStream::~NetStream() {
disconnect();
}
void NetStream::set_state(State s) {
if (m_state == s) {
return;
}
m_state = s;
on_state_change(s);
}
bool NetStream::connect(const std::string &host, uint16_t port) {
if (!SecureCipher::ensure_sodium_init()) {
m_last_error = "sodium_init failed";
return false;
}
disconnect();
addrinfo hints{};
hints.ai_family = AF_INET;
hints.ai_socktype = SOCK_STREAM;
addrinfo *res = nullptr;
char portbuf[8];
std::snprintf(portbuf, sizeof(portbuf), "%u", port);
if (getaddrinfo(host.c_str(), portbuf, &hints, &res) != 0 || !res) {
m_last_error = "getaddrinfo(" + host + ") failed";
return false;
}
m_sock = ::socket(res->ai_family, res->ai_socktype, res->ai_protocol);
if (m_sock < 0) {
m_last_error = std::string("socket: ") + std::strerror(errno);
freeaddrinfo(res);
return false;
}
set_nonblocking(m_sock);
int one = 1;
setsockopt(m_sock, IPPROTO_TCP, TCP_NODELAY, &one, sizeof(one));
int rc = ::connect(m_sock, res->ai_addr, res->ai_addrlen);
freeaddrinfo(res);
if (rc == 0) {
set_state(State::Online);
} else if (errno == EINPROGRESS || errno == EWOULDBLOCK) {
set_state(State::Connecting);
} else {
m_last_error = std::string("connect: ") + std::strerror(errno);
::close(m_sock);
m_sock = -1;
return false;
}
m_last_error.clear();
return true;
}
void NetStream::disconnect() {
if (m_sock >= 0) {
::close(m_sock);
m_sock = -1;
}
m_recv.clear();
m_send.clear();
m_cipher.clean_up();
if (m_state != State::Offline) {
set_state(State::Offline);
}
}
bool NetStream::send_packet(const void *struct_bytes, size_t n) {
if (m_sock < 0 || n < PACKET_HEADER_SIZE) {
return false;
}
// copy so we can encrypt in place without touching the caller's struct
const auto *src = static_cast<const uint8_t *>(struct_bytes);
uint8_t stackbuf[512];
uint8_t *tmp = n <= sizeof(stackbuf) ? stackbuf : new uint8_t[n];
std::memcpy(tmp, src, n);
if (m_trace) {
const size_t shown = n < 96 ? n : 96;
std::fprintf(stderr, "[tx-plain %zuB]", n);
for (size_t i = 0; i < shown; ++i) {
std::fprintf(stderr, " %02x", tmp[i]);
}
if (shown != n) {
std::fprintf(stderr, " ...");
}
std::fprintf(stderr, "\n");
}
m_cipher.encrypt_in_place(tmp, n); // no-op until activated; advances tx counter
if (m_trace) {
const size_t shown = n < 96 ? n : 96;
std::fprintf(stderr, "[tx-wire %zuB]", n);
for (size_t i = 0; i < shown; ++i) {
std::fprintf(stderr, " %02x", tmp[i]);
}
if (shown != n) {
std::fprintf(stderr, " ...");
}
std::fprintf(stderr, "\n");
}
m_send.write(tmp, n);
if (tmp != stackbuf) {
delete[] tmp;
}
flush_send();
return true;
}
bool NetStream::recv_into_buffer() {
uint8_t chunk[8192];
ssize_t r = ::recv(m_sock, chunk, sizeof(chunk), 0);
if (r > 0) {
if (m_cipher.is_activated()) {
m_cipher.decrypt_in_place(chunk, static_cast<size_t>(r));
}
if (m_trace) {
size_t n = (size_t)r < 48 ? (size_t)r : 48;
std::fprintf(stderr, "[rx %zdB]", (ssize_t)r);
for (size_t i = 0; i < n; ++i) {
std::fprintf(stderr, " %02x", chunk[i]);
}
std::fprintf(stderr, "\n");
}
m_recv.write(chunk, static_cast<size_t>(r));
return true;
}
if (r == 0) {
m_last_error = "peer closed";
return false;
}
if (errno == EWOULDBLOCK || errno == EAGAIN) {
return true;
}
m_last_error = std::string("recv: ") + std::strerror(errno);
return false;
}
bool NetStream::flush_send() {
while (m_send.readable() > 0) {
ssize_t w = ::send(m_sock, m_send.read_ptr(), m_send.readable(), 0);
if (w > 0) {
m_send.discard(static_cast<size_t>(w));
continue;
}
if (w < 0 && (errno == EWOULDBLOCK || errno == EAGAIN)) {
return true; // try again next process()
}
m_last_error = std::string("send: ") + std::strerror(errno);
return false;
}
return true;
}
void NetStream::process() {
if (m_sock < 0) {
return;
}
fd_set rfd, wfd;
FD_ZERO(&rfd);
FD_ZERO(&wfd);
FD_SET(m_sock, &rfd);
FD_SET(m_sock, &wfd);
timeval tv{0, 0};
if (select(m_sock + 1, &rfd, &wfd, nullptr, &tv) < 0) {
return;
}
if (m_state == State::Connecting) {
if (FD_ISSET(m_sock, &wfd)) {
int err = 0;
socklen_t l = sizeof(err);
getsockopt(m_sock, SOL_SOCKET, SO_ERROR, &err, &l);
if (err != 0) {
m_last_error = std::string("connect: ") + std::strerror(err);
on_disconnect();
disconnect();
return;
}
set_state(State::Online);
} else {
return;
}
}
if (FD_ISSET(m_sock, &wfd) && m_send.readable() > 0) {
if (!flush_send()) {
on_disconnect();
disconnect();
return;
}
}
if (FD_ISSET(m_sock, &rfd)) {
if (!recv_into_buffer()) {
on_disconnect();
disconnect();
return;
}
}
if (!dispatch()) {
on_disconnect();
disconnect();
}
}
bool NetStream::dispatch() {
for (;;) {
PacketHeaderPeek hp;
if (!m_recv.peek(&hp, sizeof(hp))) {
return true; // need more bytes
}
// skip zero padding from cipher block alignment
if (hp.header == 0) {
uint16_t z;
m_recv.read(&z, sizeof(z));
continue;
}
// give a subclass first crack at framing this header (guild-mark stream).
switch (on_raw(hp.header)) {
case Raw::NeedMore:
return true;
case Raw::Consumed:
continue;
case Raw::Error:
return false;
case Raw::NotHandled:
break;
}
DynHeader dh;
if (!m_recv.peek(&dh, sizeof(dh))) {
return true;
}
if (dh.length < PACKET_HEADER_SIZE || dh.length > MAX_PACKET_LENGTH) {
m_last_error = "bad packet length " + std::to_string(dh.length) +
" for header 0x" + std::to_string(dh.header);
return false;
}
if (!m_recv.has(dh.length)) {
return true; // wait for the whole packet
}
if (m_trace) {
std::fprintf(stderr, "[frame] header=0x%04X length=%u avail=%zu\n", dh.header,
dh.length, m_recv.readable());
}
bool ok = true;
switch (dh.header) {
case GC_PHASE:
ok = handle_phase();
break;
case GC_PING:
ok = handle_ping();
break;
case GC_KEY_CHALLENGE:
ok = handle_key_challenge();
break;
case GC_KEY_COMPLETE:
ok = handle_key_complete();
break;
default: {
size_t before = m_recv.readable();
ok = on_packet(dh.header, dh.length);
size_t after = m_recv.readable();
if (m_trace) {
std::fprintf(stderr, "[frame] consumed %zu (expected %u)\n", before - after,
dh.length);
}
// safety: on_packet must consume at least the 4-byte frame header,
// otherwise dispatch() would spin on the same bytes forever.
if (ok && before == after) {
m_last_error = "on_packet consumed nothing for header 0x" +
std::to_string(dh.header);
return false;
}
break;
}
}
if (!ok) {
return false;
}
}
}
bool NetStream::handle_phase() {
GCPhase p;
if (!m_recv.read(&p, sizeof(p))) {
return false;
}
on_phase(p.phase);
return true;
}
bool NetStream::handle_ping() {
GCPing p;
if (!m_recv.read(&p, sizeof(p))) {
return false;
}
CGPong pong{CG_PONG, sizeof(CGPong)};
return send_packet(&pong, sizeof(pong));
}
bool NetStream::handle_key_challenge() {
GCKeyChallenge kc;
if (!m_recv.read(&kc, sizeof(kc))) {
return false;
}
if (!m_cipher.initialize() || !m_cipher.compute_client_keys(kc.server_pk)) {
m_last_error = "cipher key exchange failed";
return false;
}
CGKeyResponse resp{};
resp.header = CG_KEY_RESPONSE;
resp.length = sizeof(resp);
m_cipher.get_public_key(resp.client_pk);
m_cipher.compute_challenge_response(kc.challenge, resp.challenge_response);
return send_packet(&resp, sizeof(resp));
}
bool NetStream::handle_key_complete() {
GCKeyComplete kc;
if (!m_recv.read(&kc, sizeof(kc))) {
return false;
}
uint8_t token[SecureCipher::SESSION_TOKEN_SIZE];
if (!m_cipher.decrypt_token(kc.encrypted_token, sizeof(kc.encrypted_token), kc.nonce, token)) {
m_last_error = "session token decrypt failed";
return false;
}
m_cipher.set_session_token(token);
m_cipher.set_activated(true);
// bytes already buffered after this packet are the first ciphertext bytes;
// rx counter is 0, decrypt them in place now.
size_t pending = m_recv.readable();
if (pending > 0) {
m_cipher.decrypt_in_place(m_recv.mutable_unread(), pending);
}
on_cipher_active();
return true;
}
} // namespace mtnet
+92
View File
@@ -0,0 +1,92 @@
#pragma once
// NetStream — non-blocking TCP + Metin2 wire framing + libsodium handshake.
// POSIX sockets (macOS Phase 1). Ported from EterLib/NetStream.cpp + the
// control-plane handlers, minus Winsock and the Python phase glue.
//
// Lifecycle: connect() -> call process() every frame -> on_state_change /
// on_phase / on_packet callbacks fire. The base class handles GC_PHASE,
// GC_PING/CG_PONG and the KX handshake (GC_KEY_CHALLENGE / GC_KEY_COMPLETE)
// itself; subclasses handle everything else in on_packet().
#include "byte_buffer.h"
#include "secure_cipher.h"
#include "wire.h"
#include <cstdint>
#include <string>
namespace mtnet {
class NetStream {
public:
enum class State { Offline, Connecting, Online };
NetStream() = default;
virtual ~NetStream();
bool connect(const std::string &host, uint16_t port);
void disconnect();
void process(); // pump once per frame
State state() const { return m_state; }
bool is_online() const { return m_state == State::Online; }
bool cipher_active() const { return m_cipher.is_activated(); }
const uint8_t *session_token() const { return m_cipher.session_token(); }
const std::string &last_error() const { return m_last_error; }
void set_wire_trace(bool on) { m_trace = on; }
// Frame + (if active) encrypt `struct_bytes` and queue for send. `n` must be
// the full packet size; the first 4 bytes are [header][length].
bool send_packet(const void *struct_bytes, size_t n);
protected:
// Result of on_raw(): let a subclass frame packets the standard u16-length
// dispatcher can't (e.g. the guild-mark stream's u32 buf_size bodies).
enum class Raw { NotHandled, NeedMore, Consumed, Error };
// Overridable hooks.
virtual void on_state_change(State) {}
virtual void on_phase(uint8_t /*phase*/) {}
// Fired once, right after the KX handshake completes and the cipher turns on.
virtual void on_cipher_active() {}
// Called for every non-zero, non-control header BEFORE the standard u16-length
// gate. Inspect recv_ptr()/recv_avail(); consume via recv_bytes()/recv_discard().
// Return NotHandled to fall through to the normal on_packet() path.
virtual Raw on_raw(uint16_t /*header*/) { return Raw::NotHandled; }
// Non-control packet in the recv buffer. `len` bytes are guaranteed present.
// Consume exactly `len` bytes via recv_bytes(); return false to abort.
virtual bool on_packet(uint16_t /*header*/, uint16_t /*len*/) { return true; }
virtual void on_disconnect() {}
// Buffer accessors for on_packet() / on_raw() implementations.
bool peek_bytes(void *dst, size_t n) const { return m_recv.peek(dst, n); }
bool recv_bytes(void *dst, size_t n) { return m_recv.read(dst, n); }
const uint8_t *recv_ptr() const { return m_recv.read_ptr(); }
size_t recv_avail() const { return m_recv.readable(); }
void recv_discard(size_t n) { m_recv.discard(n); }
void drop_recv() { m_recv.clear(); }
// bytes still queued for send (not yet handed to the socket).
size_t send_pending() const { return m_send.readable(); }
private:
void set_state(State s);
bool recv_into_buffer(); // one recv() call
bool flush_send(); // one send() call
bool dispatch(); // consume complete packets from m_recv
bool handle_key_challenge();
bool handle_key_complete();
bool handle_ping();
bool handle_phase();
int m_sock = -1;
State m_state = State::Offline;
std::string m_last_error;
ByteBuffer m_recv;
ByteBuffer m_send;
SecureCipher m_cipher;
bool m_trace = false;
};
} // namespace mtnet
+141
View File
@@ -0,0 +1,141 @@
#include "secure_cipher.h"
namespace mtnet {
bool SecureCipher::ensure_sodium_init() {
static bool done = false;
if (!done) {
if (sodium_init() < 0) {
return false;
}
done = true;
}
return true;
}
bool SecureCipher::initialize() {
if (!ensure_sodium_init()) {
return false;
}
if (crypto_kx_keypair(m_pk, m_sk) != 0) {
return false;
}
m_tx_nonce = 0;
m_rx_nonce = 0;
m_initialized = true;
m_activated = false;
return true;
}
void SecureCipher::clean_up() {
sodium_memzero(m_pk, sizeof(m_pk));
sodium_memzero(m_sk, sizeof(m_sk));
sodium_memzero(m_tx_key, sizeof(m_tx_key));
sodium_memzero(m_rx_key, sizeof(m_rx_key));
sodium_memzero(m_tx_stream_nonce, sizeof(m_tx_stream_nonce));
sodium_memzero(m_rx_stream_nonce, sizeof(m_rx_stream_nonce));
sodium_memzero(m_session_token, sizeof(m_session_token));
m_initialized = false;
m_activated = false;
m_tx_nonce = 0;
m_rx_nonce = 0;
}
bool SecureCipher::compute_client_keys(const uint8_t *server_pk) {
if (!m_initialized) {
return false;
}
// client: rx_key decrypts S->C, tx_key encrypts C->S
if (crypto_kx_client_session_keys(m_rx_key, m_tx_key, m_pk, m_sk, server_pk) != 0) {
return false;
}
sodium_memzero(m_tx_stream_nonce, NONCE_SIZE);
m_tx_stream_nonce[0] = 0x02; // C->S
sodium_memzero(m_rx_stream_nonce, NONCE_SIZE);
m_rx_stream_nonce[0] = 0x01; // S->C
return true;
}
bool SecureCipher::compute_server_keys(const uint8_t *client_pk) {
if (!m_initialized) {
return false;
}
if (crypto_kx_server_session_keys(m_rx_key, m_tx_key, m_pk, m_sk, client_pk) != 0) {
return false;
}
sodium_memzero(m_tx_stream_nonce, NONCE_SIZE);
m_tx_stream_nonce[0] = 0x01; // S->C
sodium_memzero(m_rx_stream_nonce, NONCE_SIZE);
m_rx_stream_nonce[0] = 0x02; // C->S
return true;
}
void SecureCipher::compute_challenge_response(const uint8_t *challenge, uint8_t *out) const {
crypto_auth(out, challenge, CHALLENGE_SIZE, m_tx_key);
}
bool SecureCipher::verify_challenge_response(const uint8_t *challenge, const uint8_t *response) const {
return crypto_auth_verify(response, challenge, CHALLENGE_SIZE, m_rx_key) == 0;
}
void SecureCipher::apply_stream(void *buffer, size_t len, const uint8_t *key,
uint64_t &byte_counter, const uint8_t *stream_nonce) {
uint8_t *p = static_cast<uint8_t *>(buffer);
// partial leading block if the counter isn't 64-byte aligned
uint32_t offset = static_cast<uint32_t>(byte_counter % 64);
if (offset != 0 && len > 0) {
uint8_t ks[64];
sodium_memzero(ks, 64);
crypto_stream_xchacha20_xor_ic(ks, ks, 64, stream_nonce, byte_counter / 64, key);
size_t use = len < (64 - offset) ? len : (64 - offset);
for (size_t i = 0; i < use; ++i) {
p[i] ^= ks[offset + i];
}
p += use;
len -= use;
byte_counter += use;
}
if (len > 0) {
crypto_stream_xchacha20_xor_ic(p, p, static_cast<unsigned long long>(len), stream_nonce,
byte_counter / 64, key);
byte_counter += len;
}
}
void SecureCipher::encrypt_in_place(void *buffer, size_t len) {
if (!m_activated || len == 0) {
return;
}
apply_stream(buffer, len, m_tx_key, m_tx_nonce, m_tx_stream_nonce);
}
void SecureCipher::decrypt_in_place(void *buffer, size_t len) {
if (!m_activated || len == 0) {
return;
}
apply_stream(buffer, len, m_rx_key, m_rx_nonce, m_rx_stream_nonce);
}
bool SecureCipher::encrypt_token(const uint8_t *plaintext, size_t len, uint8_t *ciphertext,
uint8_t *nonce_out) const {
if (!m_initialized) {
return false;
}
randombytes_buf(nonce_out, NONCE_SIZE);
unsigned long long clen = 0;
return crypto_aead_xchacha20poly1305_ietf_encrypt(ciphertext, &clen, plaintext, len, nullptr,
0, nullptr, nonce_out, m_tx_key) == 0;
}
bool SecureCipher::decrypt_token(const uint8_t *ciphertext, size_t len, const uint8_t *nonce,
uint8_t *plaintext) const {
if (!m_initialized) {
return false;
}
unsigned long long plen = 0;
return crypto_aead_xchacha20poly1305_ietf_decrypt(plaintext, &plen, nullptr, ciphertext, len,
nullptr, 0, nonce, m_rx_key) == 0;
}
} // namespace mtnet
+99
View File
@@ -0,0 +1,99 @@
#pragma once
// SecureCipher — libsodium key exchange + stream cipher for the Metin2 net
// protocol (this m2dev fork). Ported near-verbatim from the client source
// EterBase/SecureCipher.{h,cpp}; only the logging calls were dropped so this
// has no dependency beyond libsodium (unit-testable standalone).
//
// Handshake (client side):
// recv GC_KEY_CHALLENGE{server_pk, challenge} -> Initialize(); ComputeClientKeys(server_pk)
// send CG_KEY_RESPONSE{GetPublicKey(), ComputeChallengeResponse(challenge)}
// recv GC_KEY_COMPLETE{encrypted_token, nonce} -> DecryptToken(); SetSessionToken(); SetActivated(true)
// After activation every byte on the wire is XChaCha20 keystream-XOR'd, per
// direction, with a running byte counter (order-sensitive).
#include <sodium.h>
#include <cstdint>
#include <cstring>
namespace mtnet {
class SecureCipher {
public:
static constexpr size_t PK_SIZE = crypto_kx_PUBLICKEYBYTES; // 32
static constexpr size_t SK_SIZE = crypto_kx_SECRETKEYBYTES; // 32
static constexpr size_t KEY_SIZE = crypto_kx_SESSIONKEYBYTES; // 32
static constexpr size_t NONCE_SIZE = crypto_aead_xchacha20poly1305_ietf_NPUBBYTES; // 24
static constexpr size_t TAG_SIZE = crypto_aead_xchacha20poly1305_ietf_ABYTES; // 16
static constexpr size_t CHALLENGE_SIZE = 32;
static constexpr size_t SESSION_TOKEN_SIZE = 32;
SecureCipher() {
sodium_memzero(m_pk, sizeof(m_pk));
sodium_memzero(m_sk, sizeof(m_sk));
sodium_memzero(m_tx_key, sizeof(m_tx_key));
sodium_memzero(m_rx_key, sizeof(m_rx_key));
sodium_memzero(m_tx_stream_nonce, sizeof(m_tx_stream_nonce));
sodium_memzero(m_rx_stream_nonce, sizeof(m_rx_stream_nonce));
sodium_memzero(m_session_token, sizeof(m_session_token));
}
~SecureCipher() { clean_up(); }
static bool ensure_sodium_init();
// Generate this endpoint's X25519 keypair.
bool initialize();
void clean_up();
void get_public_key(uint8_t *out_pk) const { memcpy(out_pk, m_pk, PK_SIZE); }
// Derive session keys. Client uses the server's public key; server uses the
// client's. Sets the fixed per-direction stream nonces (0x01 = S->C, 0x02 = C->S).
bool compute_client_keys(const uint8_t *server_pk);
bool compute_server_keys(const uint8_t *client_pk);
// HMAC(challenge, tx_key). Peer verifies with its rx_key (== our tx_key).
void compute_challenge_response(const uint8_t *challenge, uint8_t *out_response) const;
bool verify_challenge_response(const uint8_t *challenge, const uint8_t *response) const;
// In-place XChaCha20 keystream XOR for wire buffers. Same length in/out; the
// running byte counter must advance over exactly the bytes sent/received, in
// order. No-op until activated.
void encrypt_in_place(void *buffer, size_t len);
void decrypt_in_place(void *buffer, size_t len);
// One-shot AEAD (XChaCha20-Poly1305) for the KeyComplete session token.
bool encrypt_token(const uint8_t *plaintext, size_t len, uint8_t *ciphertext,
uint8_t *nonce_out) const;
bool decrypt_token(const uint8_t *ciphertext, size_t len, const uint8_t *nonce,
uint8_t *plaintext) const;
bool is_activated() const { return m_activated; }
void set_activated(bool v) { m_activated = v; }
bool is_initialized() const { return m_initialized; }
void set_session_token(const uint8_t *token) { memcpy(m_session_token, token, SESSION_TOKEN_SIZE); }
const uint8_t *session_token() const { return m_session_token; }
uint64_t tx_nonce() const { return m_tx_nonce; }
uint64_t rx_nonce() const { return m_rx_nonce; }
private:
void apply_stream(void *buffer, size_t len, const uint8_t *key,
uint64_t &byte_counter, const uint8_t *stream_nonce);
bool m_initialized = false;
bool m_activated = false;
uint8_t m_pk[PK_SIZE];
uint8_t m_sk[SK_SIZE];
uint8_t m_tx_key[KEY_SIZE];
uint8_t m_rx_key[KEY_SIZE];
uint64_t m_tx_nonce = 0;
uint64_t m_rx_nonce = 0;
uint8_t m_tx_stream_nonce[NONCE_SIZE];
uint8_t m_rx_stream_nonce[NONCE_SIZE];
uint8_t m_session_token[SESSION_TOKEN_SIZE];
};
} // namespace mtnet
File diff suppressed because it is too large Load Diff
+83
View File
@@ -0,0 +1,83 @@
#include "asset_source.h"
#include <cstdio>
#include <filesystem>
#include <fstream>
namespace fs = std::filesystem;
namespace mtpack {
bool AssetSource::build(const std::string &assets_root, const std::string &pack_dir,
const std::string &cache_dir, std::string *err) {
if (!m_loose.build(assets_root, fmt::AssetResolver::default_priority(), err)) {
return false;
}
if (!pack_dir.empty()) {
int n = m_packs.scan_dir(pack_dir);
m_have_packs = n > 0;
m_cache = !cache_dir.empty()
? cache_dir
: (fs::path(pack_dir) / ".mtcache").string();
}
return true;
}
bool AssetSource::exists(const std::string &vpath) const {
if (!m_loose.resolve(vpath).empty()) {
return true;
}
return m_have_packs && m_packs.has(vpath);
}
bool AssetSource::read(const std::string &vpath, std::vector<uint8_t> &out, std::string *err) const {
std::string lp = m_loose.resolve(vpath);
if (!lp.empty()) {
std::ifstream f(lp, std::ios::binary);
if (f) {
out.assign((std::istreambuf_iterator<char>(f)), std::istreambuf_iterator<char>());
return true;
}
}
if (m_have_packs && m_packs.read(vpath, out, err)) {
return true;
}
if (err && err->empty()) {
*err = "not found: " + vpath;
}
return false;
}
std::string AssetSource::to_path(const std::string &vpath, std::string *err) const {
std::string lp = m_loose.resolve(vpath);
if (!lp.empty()) {
return lp;
}
if (!m_have_packs || !m_packs.has(vpath)) {
if (err) *err = "not found: " + vpath;
return "";
}
// extract to cache/<normalized vpath>, once
std::string rel = PackMount::norm(vpath);
fs::path dst = fs::path(m_cache) / rel;
std::error_code ec;
if (fs::exists(dst, ec) && fs::file_size(dst, ec) > 0) {
return dst.string();
}
std::vector<uint8_t> bytes;
if (!m_packs.read(vpath, bytes, err)) {
return "";
}
fs::create_directories(dst.parent_path(), ec);
std::ofstream o(dst, std::ios::binary | std::ios::trunc);
if (!o) {
if (err) *err = "cannot write cache file " + dst.string();
return "";
}
o.write(reinterpret_cast<const char *>(bytes.data()),
static_cast<std::streamsize>(bytes.size()));
o.close();
return dst.string();
}
} // namespace mtpack
+48
View File
@@ -0,0 +1,48 @@
#pragma once
// AssetSource —— unified asset lookup over loose files + .epk packs.
//
// src.build(assets_root, pack_dir);
// auto p = src.to_path("d:/ymir work/ui/pattern/board_base.tga");
// // p is a real filesystem path: the loose file if present, else the pack
// // entry extracted once into <cache>/... . Callers that expect a path keep
// // working; new code can use read() for bytes directly.
//
// Precedence (dev-friendly, matches the client's pack/ overlay): a loose file
// wins over a packed one. Within packs, later-mounted (patch) wins.
#include "asset_resolver.h" // xrender::formats
#include "pack_mount.h"
#include <cstdint>
#include <string>
#include <vector>
namespace mtpack {
class AssetSource {
public:
// `pack_dir` may be empty (loose-only). `cache_dir` is where packed files
// get extracted for to_path(); defaults to <pack_dir>/.mtcache or a temp dir.
bool build(const std::string &assets_root, const std::string &pack_dir = "",
const std::string &cache_dir = "", std::string *err = nullptr);
bool exists(const std::string &vpath) const;
// Real path for `vpath`: loose file, or a pack entry extracted to the cache.
// Empty string if not found anywhere.
std::string to_path(const std::string &vpath, std::string *err = nullptr) const;
// Bytes for `vpath` (loose read or pack decompress). false if not found.
bool read(const std::string &vpath, std::vector<uint8_t> &out, std::string *err = nullptr) const;
const fmt::AssetResolver &loose() const { return m_loose; }
const PackMount &packs() const { return m_packs; }
private:
fmt::AssetResolver m_loose;
PackMount m_packs;
std::string m_cache;
bool m_have_packs = false;
};
} // namespace mtpack
+266
View File
@@ -0,0 +1,266 @@
#include "eterpack.h"
#include <sodium.h>
#include <zstd.h>
#include <algorithm>
#include <cstring>
#include <fstream>
namespace mtpack {
namespace {
constexpr size_t HEADER_SIZE = 8 + 8 + PACK_NONCE_SIZE; // 40
// entry tail after the name field: offset,file_size,compressed_size,encryption,nonce
constexpr size_t ENTRY_TAIL = 8 + 8 + 8 + 1 + PACK_NONCE_SIZE; // 49
void xchacha20(uint8_t *data, size_t len, const uint8_t *nonce) {
crypto_stream_xchacha20_xor(data, data, len, nonce, PACK_KEY);
}
// Parse one decrypted entry blob of `field + ENTRY_TAIL` bytes.
Entry parse_entry(const uint8_t *p, int field) {
Entry e;
size_t nlen = strnlen(reinterpret_cast<const char *>(p), field);
e.name.assign(reinterpret_cast<const char *>(p), nlen);
const uint8_t *q = p + field;
std::memcpy(&e.offset, q, 8);
q += 8;
std::memcpy(&e.file_size, q, 8);
q += 8;
std::memcpy(&e.compressed_size, q, 8);
q += 8;
e.encryption = *q++;
std::memcpy(e.nonce, q, PACK_NONCE_SIZE);
return e;
}
bool plausible(const Entry &e, uint64_t data_begin, uint64_t file_total) {
if (e.encryption > 1) {
return false;
}
// empty files are legal; a zstd frame is never 0 bytes though
if (e.compressed_size == 0 || e.compressed_size > file_total) {
return false;
}
if (data_begin + e.offset + e.compressed_size > file_total) {
return false;
}
if (e.name.empty()) {
return false;
}
// filenames may be CP949 (Korean) — reject only ASCII control bytes.
for (unsigned char c : e.name) {
if (c < 0x20) {
return false;
}
}
return true;
}
} // namespace
std::string EterPack::norm(std::string s) {
std::replace(s.begin(), s.end(), '\\', '/');
std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c) { return std::tolower(c); });
return s;
}
void EterPack::close() {
m_file.clear();
m_index.clear();
m_by_name.clear();
m_data_begin = 0;
}
bool EterPack::open(const std::string &path, std::string *err) {
close();
if (sodium_init() < 0) {
if (err) *err = "sodium_init failed";
return false;
}
std::ifstream f(path, std::ios::binary);
if (!f) {
if (err) *err = "cannot open " + path;
return false;
}
f.seekg(0, std::ios::end);
std::streamoff sz = f.tellg();
f.seekg(0);
if (sz < static_cast<std::streamoff>(HEADER_SIZE)) {
if (err) *err = "file too small";
return false;
}
m_file.resize(static_cast<size_t>(sz));
f.read(reinterpret_cast<char *>(m_file.data()), sz);
uint64_t entry_num = 0;
std::memcpy(&entry_num, m_file.data(), 8);
std::memcpy(&m_data_begin, m_file.data() + 8, 8);
const uint8_t *hnonce = m_file.data() + 16;
if (entry_num == 0 || m_data_begin < HEADER_SIZE || m_data_begin > m_file.size()) {
if (err) *err = "bad header";
return false;
}
const uint64_t index_bytes = m_data_begin - HEADER_SIZE;
// Candidate name-field sizes: derived first, then the known platform values.
std::vector<int> candidates;
if (index_bytes % entry_num == 0) {
int64_t es = static_cast<int64_t>(index_bytes / entry_num);
if (es > static_cast<int64_t>(ENTRY_TAIL) + 1) {
candidates.push_back(static_cast<int>(es - ENTRY_TAIL));
}
}
for (int v : {261, 4097, 1025, 256}) {
candidates.push_back(v);
}
for (int field : candidates) {
const size_t entry_size = static_cast<size_t>(field) + ENTRY_TAIL;
if (HEADER_SIZE + entry_num * entry_size > m_file.size()) {
continue;
}
std::vector<Entry> idx;
idx.reserve(entry_num);
bool ok = true;
std::vector<uint8_t> blob(entry_size);
for (uint64_t i = 0; i < entry_num && ok; ++i) {
std::memcpy(blob.data(), m_file.data() + HEADER_SIZE + i * entry_size, entry_size);
xchacha20(blob.data(), entry_size, hnonce);
Entry e = parse_entry(blob.data(), field);
if (!plausible(e, m_data_begin, m_file.size())) {
ok = false;
break;
}
idx.push_back(std::move(e));
}
if (ok) {
m_index = std::move(idx);
m_name_field = field;
for (size_t i = 0; i < m_index.size(); ++i) {
m_by_name[norm(m_index[i].name)] = i;
}
return true;
}
}
if (err) *err = "could not resolve index layout (name-field guess failed)";
return false;
}
std::vector<std::string> EterPack::names() const {
std::vector<std::string> v;
v.reserve(m_index.size());
for (const auto &e : m_index) {
v.push_back(e.name);
}
return v;
}
bool EterPack::read(const std::string &name, std::vector<uint8_t> &out, std::string *err) const {
auto it = m_by_name.find(norm(name));
if (it == m_by_name.end()) {
if (err) *err = "not in pack: " + name;
return false;
}
const Entry &e = m_index[it->second];
const uint8_t *src = m_file.data() + m_data_begin + e.offset;
std::vector<uint8_t> comp(e.compressed_size);
std::memcpy(comp.data(), src, e.compressed_size);
if (e.encryption == 1) {
crypto_stream_xchacha20_xor(comp.data(), comp.data(), comp.size(), e.nonce, PACK_KEY);
}
out.resize(e.file_size);
size_t n = ZSTD_decompress(out.data(), out.size(), comp.data(), comp.size());
if (ZSTD_isError(n) || n != e.file_size) {
if (err) *err = std::string("zstd: ") + (ZSTD_isError(n) ? ZSTD_getErrorName(n) : "size mismatch");
return false;
}
return true;
}
// --- writer ----------------------------------------------------------------
bool write_pack(const std::string &out_path, const std::vector<InputFile> &files, bool encrypt,
std::string *err) {
if (sodium_init() < 0) {
if (err) *err = "sodium_init failed";
return false;
}
const int field = PACK_NAME_FIELD_DEFAULT;
const size_t entry_size = static_cast<size_t>(field) + ENTRY_TAIL;
const uint64_t entry_num = files.size();
const uint64_t data_begin = HEADER_SIZE + entry_num * entry_size;
uint8_t header_nonce[PACK_NONCE_SIZE];
randombytes_buf(header_nonce, sizeof(header_nonce));
std::vector<uint8_t> index(entry_num * entry_size, 0);
std::vector<uint8_t> data;
uint64_t cursor = 0;
for (uint64_t i = 0; i < entry_num; ++i) {
const InputFile &in = files[i];
size_t bound = ZSTD_compressBound(in.data.size());
std::vector<uint8_t> comp(bound);
size_t clen = ZSTD_compress(comp.data(), comp.size(), in.data.data(), in.data.size(), 3);
if (ZSTD_isError(clen)) {
if (err) *err = std::string("zstd compress: ") + ZSTD_getErrorName(clen);
return false;
}
comp.resize(clen);
Entry e;
e.name = in.name;
e.offset = cursor;
e.file_size = in.data.size();
e.compressed_size = clen;
e.encryption = encrypt ? 1 : 0;
if (encrypt) {
randombytes_buf(e.nonce, sizeof(e.nonce));
crypto_stream_xchacha20_xor(comp.data(), comp.data(), comp.size(), e.nonce, PACK_KEY);
}
// serialize entry (plaintext), then encrypt the whole index blob at the end
uint8_t *p = index.data() + i * entry_size;
std::string nm = e.name;
std::replace(nm.begin(), nm.end(), '\\', '/');
std::memcpy(p, nm.data(), std::min<size_t>(nm.size(), field - 1));
uint8_t *q = p + field;
std::memcpy(q, &e.offset, 8);
q += 8;
std::memcpy(q, &e.file_size, 8);
q += 8;
std::memcpy(q, &e.compressed_size, 8);
q += 8;
*q++ = e.encryption;
std::memcpy(q, e.nonce, PACK_NONCE_SIZE);
data.insert(data.end(), comp.begin(), comp.end());
cursor += clen;
}
// encrypt the index with the header nonce
for (uint64_t i = 0; i < entry_num; ++i) {
crypto_stream_xchacha20_xor(index.data() + i * entry_size, index.data() + i * entry_size,
entry_size, header_nonce, PACK_KEY);
}
std::ofstream f(out_path, std::ios::binary | std::ios::trunc);
if (!f) {
if (err) *err = "cannot write " + out_path;
return false;
}
f.write(reinterpret_cast<const char *>(&entry_num), 8);
f.write(reinterpret_cast<const char *>(&data_begin), 8);
f.write(reinterpret_cast<const char *>(header_nonce), PACK_NONCE_SIZE);
f.write(reinterpret_cast<const char *>(index.data()), static_cast<std::streamsize>(index.size()));
f.write(reinterpret_cast<const char *>(data.data()), static_cast<std::streamsize>(data.size()));
return static_cast<bool>(f);
}
} // namespace mtpack
+83
View File
@@ -0,0 +1,83 @@
#pragma once
// EterPack — reader/writer for this m2dev fork's single-file asset pack
// (PackLib/Pack.cpp). NOT the classic Metin2 .eix/.epk format.
//
// Layout:
// [Header: u64 entry_num, u64 data_begin, u8 nonce[24]] (40 bytes)
// [Entry x entry_num] each XChaCha20-encrypted with Header.nonce + PACK_KEY
// Entry: char name[NAME_FIELD], u64 offset, u64 file_size,
// u64 compressed_size, u8 encryption, u8 nonce[24]
// [data blob @ data_begin]
// per file @ data_begin+offset, compressed_size bytes:
// encryption==0 -> zstd frame -> zstd-decompress to file_size
// encryption==1 -> XChaCha20(entry.nonce)-> zstd-decompress to file_size
//
// NAME_FIELD is FILENAME_MAX+1 on the packer's platform (MSVC PackMaker = 261).
// The reader derives it from (data_begin - 40) / entry_num, falling back to the
// known platform values, so it stays compatible with real packs.
#include <cstdint>
#include <string>
#include <unordered_map>
#include <vector>
namespace mtpack {
inline constexpr int PACK_NONCE_SIZE = 24;
inline constexpr int PACK_KEY_SIZE = 32;
// PackLib/config.h PACK_KEY — the fork ships this hardcoded.
inline constexpr uint8_t PACK_KEY[PACK_KEY_SIZE] = {
0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77,
0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF,
0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF,
0xFE, 0xDC, 0xBA, 0x98, 0x76, 0x54, 0x32, 0x10
};
// MSVC PackMaker.exe: FILENAME_MAX == 260, so the name field is 261 bytes.
inline constexpr int PACK_NAME_FIELD_DEFAULT = 261;
struct Entry {
std::string name;
uint64_t offset = 0;
uint64_t file_size = 0;
uint64_t compressed_size = 0;
uint8_t encryption = 0;
uint8_t nonce[PACK_NONCE_SIZE] = {};
};
class EterPack {
public:
// Load the index (mmaps the file; keeps it open for read()).
bool open(const std::string &path, std::string *err = nullptr);
void close();
bool has(const std::string &name) const { return m_by_name.count(norm(name)) != 0; }
std::vector<std::string> names() const;
size_t count() const { return m_index.size(); }
// Decompress (+decrypt) one file into `out`.
bool read(const std::string &name, std::vector<uint8_t> &out, std::string *err = nullptr) const;
int name_field() const { return m_name_field; }
private:
static std::string norm(std::string s); // '\\'->'/', lowercase
std::vector<uint8_t> m_file; // whole pack in memory (simple; mmap later)
uint64_t m_data_begin = 0;
std::vector<Entry> m_index;
std::unordered_map<std::string, size_t> m_by_name;
int m_name_field = PACK_NAME_FIELD_DEFAULT;
};
// Build a pack from (name, bytes) pairs. `encrypt` -> per-file encryption==1.
// Uses PACK_NAME_FIELD_DEFAULT so real clients / PackLib can read it back.
struct InputFile {
std::string name;
std::vector<uint8_t> data;
};
bool write_pack(const std::string &out_path, const std::vector<InputFile> &files, bool encrypt,
std::string *err = nullptr);
} // namespace mtpack
+113
View File
@@ -0,0 +1,113 @@
#include "pack_mount.h"
#include <algorithm>
#include <filesystem>
namespace fs = std::filesystem;
namespace mtpack {
std::string PackMount::norm(std::string s) {
std::replace(s.begin(), s.end(), '\\', '/');
// strip a leading "x:/" drive
if (s.size() > 2 && s[1] == ':') {
s = s.substr(2);
}
while (!s.empty() && s.front() == '/') {
s.erase(s.begin());
}
std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c) { return std::tolower(c); });
// fold "//"
std::string o;
o.reserve(s.size());
for (char c : s) {
if (c == '/' && !o.empty() && o.back() == '/') {
continue;
}
o.push_back(c);
}
return o;
}
bool PackMount::mount(const std::string &epk_path, std::string *err) {
auto pk = std::make_shared<EterPack>();
if (!pk->open(epk_path, err)) {
return false;
}
const size_t pi = m_packs.size();
m_packs.push_back(pk);
for (const std::string &name : pk->names()) {
std::string n = norm(name);
m_by_norm[n] = {pi, name}; // later pack wins
auto p = n.find("ymir work/");
if (p != std::string::npos) {
m_by_ymir[n.substr(p + 10)] = {pi, name};
}
}
return true;
}
int PackMount::scan_dir(const std::string &dir, const std::string &patch_prefix) {
std::error_code ec;
if (!fs::is_directory(dir, ec)) {
return 0;
}
std::vector<std::string> base, patch;
for (const auto &e : fs::directory_iterator(dir, ec)) {
if (!e.is_regular_file()) {
continue;
}
const auto &p = e.path();
if (p.extension() != ".epk") {
continue;
}
if (!patch_prefix.empty() && p.filename().string().rfind(patch_prefix, 0) == 0) {
patch.push_back(p.string());
} else {
base.push_back(p.string());
}
}
std::sort(base.begin(), base.end());
std::sort(patch.begin(), patch.end());
int n = 0;
for (const auto &v : {&base, &patch}) {
for (const std::string &f : *v) {
if (mount(f)) {
++n;
}
}
}
return n;
}
bool PackMount::has(const std::string &vp) const {
std::string n = norm(vp);
if (m_by_norm.count(n)) {
return true;
}
auto p = n.find("ymir work/");
std::string suf = (p != std::string::npos) ? n.substr(p + 10) : n;
return m_by_ymir.count(suf) != 0;
}
bool PackMount::read(const std::string &vp, std::vector<uint8_t> &out, std::string *err) const {
std::string n = norm(vp);
const Ref *r = nullptr;
if (auto it = m_by_norm.find(n); it != m_by_norm.end()) {
r = &it->second;
} else {
auto p = n.find("ymir work/");
std::string suf = (p != std::string::npos) ? n.substr(p + 10) : n;
if (auto it2 = m_by_ymir.find(suf); it2 != m_by_ymir.end()) {
r = &it2->second;
}
}
if (!r) {
if (err) *err = "not mounted: " + vp;
return false;
}
return m_packs[r->pack]->read(r->name, out, err);
}
} // namespace mtpack
+53
View File
@@ -0,0 +1,53 @@
#pragma once
// PackMount —— mount one or more .epk packs and resolve/read virtual paths
// (`d:\ymir work\...`) out of them, mirroring fmt::AssetResolver's normalization
// and "ymir work/"-suffix indexing so packed and loose assets share one lookup.
//
// Priority: packs mounted later win (call order = base packs first, patches
// last), matching the loose-file resolver's pack_priority convention.
#include "eterpack.h"
#include <memory>
#include <string>
#include <unordered_map>
#include <vector>
namespace mtpack {
class PackMount {
public:
// Open one pack and add its entries to the index. Returns false if it
// won't open; missing files are not an error for scan_dir().
bool mount(const std::string &epk_path, std::string *err = nullptr);
// Open every *.epk directly under `dir` (non-recursive), sorted by name so
// mount order is deterministic. `patch_prefix` packs (name starts with it)
// are mounted after the rest, so they override.
int scan_dir(const std::string &dir, const std::string &patch_prefix = "metin2_patch_");
bool has(const std::string &virtual_path) const;
// Decompress the file into `out`. Tries the full normalized path, then the
// "ymir work/..." suffix.
bool read(const std::string &virtual_path, std::vector<uint8_t> &out,
std::string *err = nullptr) const;
size_t pack_count() const { return m_packs.size(); }
size_t entry_count() const { return m_by_norm.size(); }
// path normalization shared with fmt::AssetResolver: '\'->'/', strip drive,
// strip leading '/', lowercase, fold '//'.
static std::string norm(std::string s);
private:
struct Ref {
size_t pack; // index into m_packs
std::string name; // exact entry name in that pack
};
std::vector<std::shared_ptr<EterPack>> m_packs;
std::unordered_map<std::string, Ref> m_by_norm; // full normalized path
std::unordered_map<std::string, Ref> m_by_ymir; // after "ymir work/"
};
} // namespace mtpack
+228
View File
@@ -0,0 +1,228 @@
#include "proto.h"
#include <sodium.h>
#include <cstring>
#include <fstream>
extern "C" {
#include <lzo/lzo1x.h>
}
namespace mtproto {
namespace {
constexpr uint32_t FOURCC_MIPX = 0x5850494D; // "MIPX" bytes 4D 49 50 58 (LE u32)
constexpr uint32_t FOURCC_MIPT = 0x5450494D; // "MIPT"
constexpr uint32_t FOURCC_MMPT = 0x54504D4D; // "MMPT"
constexpr uint32_t FOURCC_MCOZ = 0x5A4F434D; // "MCOZ"
uint32_t rd_u32(const uint8_t *p) {
uint32_t v;
std::memcpy(&v, p, 4);
return v;
}
// The fork's tea_decrypt (EterBase/tea.cpp): XChaCha20 with key/nonce derived from
// the 16-byte input key via BLAKE2b. size is rounded up to a multiple of 8.
void tea_decrypt(uint8_t *dst, const uint8_t *src, const std::array<uint32_t, 4> &key32,
size_t size) {
uint8_t key16[16];
std::memcpy(key16, key32.data(), 16); // 4 LE dwords -> raw bytes
uint8_t dkey[crypto_stream_xchacha20_KEYBYTES];
uint8_t nonce[crypto_stream_xchacha20_NONCEBYTES];
crypto_generichash(dkey, sizeof(dkey), key16, 16,
reinterpret_cast<const uint8_t *>("M2DevPackEncrypt"), 16);
uint8_t nonce_seed[crypto_stream_xchacha20_NONCEBYTES + 8];
crypto_generichash(nonce_seed, sizeof(nonce_seed), key16, 16,
reinterpret_cast<const uint8_t *>("M2DevNonce"), 10);
std::memcpy(nonce, nonce_seed, sizeof(nonce));
size_t rs = (size % 8 == 0) ? size : size + 8 - (size % 8);
crypto_stream_xchacha20_xor(dst, src, rs, nonce, dkey);
sodium_memzero(dkey, sizeof(dkey));
}
// CLZO container -> decompressed bytes.
bool clzo_decompress(const uint8_t *blob, size_t blob_len, const std::array<uint32_t, 4> &key,
std::vector<uint8_t> &out, std::string *err) {
if (blob_len < 20 || rd_u32(blob) != FOURCC_MCOZ) {
if (err) *err = "CLZO: bad MCOZ header";
return false;
}
const uint32_t enc_size = rd_u32(blob + 4);
const uint32_t comp_size = rd_u32(blob + 8);
const uint32_t real_size = rd_u32(blob + 12);
out.assign(real_size, 0);
lzo_uint out_len = real_size;
int r;
if (enc_size > 0) {
size_t rs = (enc_size % 8 == 0) ? enc_size : enc_size + 8 - (enc_size % 8);
if (16 + rs > blob_len) {
if (err) *err = "CLZO: encrypted region past end";
return false;
}
std::vector<uint8_t> dec(rs);
tea_decrypt(dec.data(), blob + 16, key, enc_size); // src = blob+16 (== m_pbIn-4)
if (rd_u32(dec.data()) != FOURCC_MCOZ) {
if (err) *err = "CLZO: wrong key (inner MCOZ mismatch)";
return false;
}
r = lzo1x_decompress_safe(dec.data() + 4, comp_size, out.data(), &out_len, nullptr);
} else {
if (20u + comp_size > blob_len) {
if (err) *err = "CLZO: compressed region past end";
return false;
}
r = lzo1x_decompress_safe(blob + 20, comp_size, out.data(), &out_len, nullptr);
}
if (r != LZO_E_OK) {
if (err) *err = "CLZO: lzo1x_decompress_safe failed (" + std::to_string(r) + ")";
return false;
}
if (out_len != real_size) {
if (err) *err = "CLZO: size mismatch " + std::to_string(out_len) + " != " +
std::to_string(real_size);
return false;
}
return true;
}
std::string cstr(const uint8_t *p, size_t maxn) {
size_t n = 0;
while (n < maxn && p[n]) {
++n;
}
return std::string(reinterpret_cast<const char *>(p), n);
}
} // namespace
bool load_proto(const std::string &path, const std::array<uint32_t, 4> &key, Proto &out,
std::string *err) {
std::ifstream f(path, std::ios::binary);
if (!f) {
if (err) *err = "cannot open " + path;
return false;
}
std::vector<uint8_t> file((std::istreambuf_iterator<char>(f)), std::istreambuf_iterator<char>());
return load_proto_bytes(file, key, out, err);
}
bool load_proto_bytes(const std::vector<uint8_t> &file, const std::array<uint32_t, 4> &key,
Proto &out, std::string *err) {
if (sodium_init() < 0) {
if (err) *err = "sodium_init failed";
return false;
}
if (lzo_init() != LZO_E_OK) {
if (err) *err = "lzo_init failed";
return false;
}
if (file.size() < 16) {
if (err) *err = "file too small";
return false;
}
const uint8_t *p = file.data();
out.fourcc = rd_u32(p);
p += 4;
uint32_t data_size = 0;
if (out.fourcc == FOURCC_MIPX) {
out.version = rd_u32(p);
p += 4;
out.stride = rd_u32(p);
p += 4;
out.elements = rd_u32(p);
p += 4;
data_size = rd_u32(p);
p += 4;
if (out.version != 1) {
if (err) *err = "MIPX version != 1";
return false;
}
} else if (out.fourcc == FOURCC_MIPT || out.fourcc == FOURCC_MMPT) {
out.elements = rd_u32(p);
p += 4;
data_size = rd_u32(p);
p += 4;
} else {
if (err) *err = "unknown proto fourcc";
return false;
}
if (static_cast<size_t>(p - file.data()) + data_size > file.size()) {
if (err) *err = "declared data_size past end of file";
return false;
}
if (!clzo_decompress(p, data_size, key, out.blob, err)) {
return false;
}
if (out.elements == 0) {
if (err) *err = "0 elements";
return false;
}
if (out.stride == 0) {
if (out.blob.size() % out.elements != 0) {
if (err) *err = "blob not divisible by element count";
return false;
}
out.stride = static_cast<uint32_t>(out.blob.size() / out.elements);
}
if (static_cast<size_t>(out.stride) * out.elements != out.blob.size()) {
if (err) *err = "stride * elements != blob size";
return false;
}
return true;
}
ItemRecord parse_item(const uint8_t *r, uint32_t stride) {
ItemRecord it;
if (!r || stride < 236) {
return it;
}
it.vnum = rd_u32(r + 0);
it.vnum_range = rd_u32(r + 4);
it.name = cstr(r + 8, 65);
it.locale_name = cstr(r + 73, 65);
it.type = r[138];
it.sub_type = r[139];
it.weight = r[140];
it.size = r[141];
it.wear_flags = rd_u32(r + 150);
it.buy_price = rd_u32(r + 158);
it.sell_price = rd_u32(r + 162);
// aLimits[2] (5B each) @166, aApplies[3] (5B each) @176, alValues[6] @191.
// (bSpecular @234 anchors the whole chain.)
for (int i = 0; i < 6; ++i) {
it.values[i] = (int32_t)rd_u32(r + 191 + i * 4);
}
it.specular = r[234];
return it;
}
MobRecord parse_mob(const uint8_t *r, uint32_t stride) {
MobRecord m;
if (!r || stride < 139) {
return m;
}
m.vnum = rd_u32(r + 0);
m.name = cstr(r + 4, 65);
m.locale_name = cstr(r + 69, 65);
m.type = r[134];
m.rank = r[135];
m.battle_type = r[136];
m.level = r[137];
m.size = r[138];
return m;
}
} // namespace mtproto
+75
View File
@@ -0,0 +1,75 @@
#pragma once
// item_proto / mob_proto reader for this m2dev fork.
//
// Outer: MIPX (item) = [u32 'MIPX'][u32 ver=1][u32 stride][u32 elements][u32 datasize][blob]
// MMPT (mob) = [u32 'MMPT'][u32 elements][u32 datasize][blob]
// Blob = CLZO container: [u32 'MCOZ'][u32 encryptSize][u32 compressedSize][u32 realSize]
// data @ blob+20. encryptSize>0 -> XChaCha20-decrypt (the fork's tea_*: key =
// BLAKE2b(key16,"M2DevPackEncrypt"), nonce = BLAKE2b(key16,"M2DevNonce")[:24]) of
// encryptSize bytes starting at blob+16, yielding [u32 'MCOZ'][lzo1x] -> LZO ->
// realSize bytes. encryptSize==0 -> LZO straight from blob+20.
// Decompressed blob = `elements` records of `stride` bytes (stride from the MIPX header,
// or realSize/elements for MMPT). Records are #pragma pack(1).
#include <array>
#include <cstdint>
#include <string>
#include <vector>
namespace mtproto {
// The 4-DWORD keys the client hardcodes (GameLib/ItemManager.cpp, PythonNonPlayer.cpp).
inline constexpr std::array<uint32_t, 4> ITEM_PROTO_KEY = {173217u, 72619434u, 408587239u, 27973291u};
inline constexpr std::array<uint32_t, 4> MOB_PROTO_KEY = {4813894u, 18955u, 552631u, 6822045u};
struct Proto {
uint32_t fourcc = 0;
uint32_t version = 0;
uint32_t stride = 0; // record size
uint32_t elements = 0; // record count
std::vector<uint8_t> blob; // elements * stride bytes
const uint8_t *record(uint32_t i) const {
return (i < elements) ? blob.data() + static_cast<size_t>(i) * stride : nullptr;
}
};
// Load + decompress. `key` is ITEM_PROTO_KEY or MOB_PROTO_KEY.
// Parse from an already-read buffer (host reads via godot::FileAccess for res://).
bool load_proto_bytes(const std::vector<uint8_t> &bytes, const std::array<uint32_t, 4> &key,
Proto &out, std::string *err);
bool load_proto(const std::string &path, const std::array<uint32_t, 4> &key, Proto &out,
std::string *err = nullptr);
// --- typed views over the leading fields (rest is offset-stable per stride) ---
struct ItemRecord {
uint32_t vnum = 0;
uint32_t vnum_range = 0;
std::string name; // szName[65] @ 8
std::string locale_name; // szLocaleName[65] @ 73
uint8_t type = 0; // @ 138
uint8_t sub_type = 0; // @ 139
uint8_t weight = 0; // @ 140
uint8_t size = 0; // @ 141
uint32_t wear_flags = 0; // @ 150
uint32_t buy_price = 0; // @ 158
uint32_t sell_price = 0; // @ 162
int32_t values[6] = {0}; // alValues[6] @ 191 (armor: values[3] = body shape index)
uint8_t specular = 0; // @ 234 -> PARITY §2.7 fSpecular = specular/100
};
ItemRecord parse_item(const uint8_t *rec, uint32_t stride);
struct MobRecord {
uint32_t vnum = 0;
std::string name; // szName[65] @ 4
std::string locale_name; // @ 69
uint8_t type = 0; // @ 134
uint8_t rank = 0; // @ 135
uint8_t battle_type = 0; // @ 136
uint8_t level = 0; // @ 137
uint8_t size = 0; // @ 138
};
MobRecord parse_mob(const uint8_t *rec, uint32_t stride);
} // namespace mtproto
+106
View File
@@ -0,0 +1,106 @@
#include "proto_node.h"
#include "../asset_io.h"
#include <godot_cpp/core/class_db.hpp>
#include <string>
using namespace godot;
namespace mtgodot {
void Metin2Proto::_bind_methods() {
ClassDB::bind_method(D_METHOD("load_item_proto", "path"), &Metin2Proto::load_item_proto);
ClassDB::bind_method(D_METHOD("load_mob_proto", "path"), &Metin2Proto::load_mob_proto);
ClassDB::bind_method(D_METHOD("item", "vnum"), &Metin2Proto::item);
ClassDB::bind_method(D_METHOD("mob", "vnum"), &Metin2Proto::mob);
ClassDB::bind_method(D_METHOD("item_count"), &Metin2Proto::item_count);
ClassDB::bind_method(D_METHOD("mob_count"), &Metin2Proto::mob_count);
ClassDB::bind_method(D_METHOD("get_last_error"), &Metin2Proto::get_last_error);
}
bool Metin2Proto::load_item_proto(const String &path) {
std::string err;
PackedByteArray bytes = mtgodot::read_file(path);
std::vector<uint8_t> buf(bytes.ptr(), bytes.ptr() + bytes.size());
if (!mtproto::load_proto_bytes(buf, mtproto::ITEM_PROTO_KEY, m_item, &err)) {
last_error = String(err.c_str());
return false;
}
m_item_ix.clear();
m_item_ix.reserve(m_item.elements);
for (uint32_t i = 0; i < m_item.elements; ++i) {
mtproto::ItemRecord r = mtproto::parse_item(m_item.record(i), m_item.stride);
m_item_ix[r.vnum] = i;
}
last_error = "";
return true;
}
bool Metin2Proto::load_mob_proto(const String &path) {
std::string err;
PackedByteArray bytes = mtgodot::read_file(path);
std::vector<uint8_t> buf(bytes.ptr(), bytes.ptr() + bytes.size());
if (!mtproto::load_proto_bytes(buf, mtproto::MOB_PROTO_KEY, m_mob, &err)) {
last_error = String(err.c_str());
return false;
}
m_mob_ix.clear();
m_mob_ix.reserve(m_mob.elements);
for (uint32_t i = 0; i < m_mob.elements; ++i) {
mtproto::MobRecord r = mtproto::parse_mob(m_mob.record(i), m_mob.stride);
m_mob_ix[r.vnum] = i;
}
last_error = "";
return true;
}
Dictionary Metin2Proto::item(int vnum) const {
Dictionary d;
auto it = m_item_ix.find((uint32_t)vnum);
if (it == m_item_ix.end() || m_item.record(it->second) == nullptr) {
return d;
}
mtproto::ItemRecord r = mtproto::parse_item(m_item.record(it->second), m_item.stride);
d["vnum"] = (int)r.vnum;
d["vnum_range"] = (int)r.vnum_range;
d["name"] = String::utf8(r.name.c_str());
d["locale_name"] = String::utf8(r.locale_name.c_str());
d["type"] = (int)r.type;
d["sub_type"] = (int)r.sub_type;
d["weight"] = (int)r.weight;
d["size"] = (int)r.size;
d["wear_flags"] = (int)r.wear_flags;
d["buy_price"] = (int)r.buy_price;
d["sell_price"] = (int)r.sell_price;
{
Array vals;
for (int i = 0; i < 6; ++i) {
vals.push_back((int)r.values[i]);
}
d["values"] = vals; // armor: values[3] = body shape index for the race .msm
}
d["specular"] = (int)r.specular;
return d;
}
Dictionary Metin2Proto::mob(int vnum) const {
Dictionary d;
auto it = m_mob_ix.find((uint32_t)vnum);
if (it == m_mob_ix.end() || m_mob.record(it->second) == nullptr) {
return d;
}
mtproto::MobRecord r = mtproto::parse_mob(m_mob.record(it->second), m_mob.stride);
d["vnum"] = (int)r.vnum;
d["name"] = String::utf8(r.name.c_str());
d["locale_name"] = String::utf8(r.locale_name.c_str());
d["type"] = (int)r.type;
d["rank"] = (int)r.rank;
d["battle_type"] = (int)r.battle_type;
d["level"] = (int)r.level;
d["size"] = (int)r.size;
return d;
}
} // namespace mtgodot
+48
View File
@@ -0,0 +1,48 @@
#pragma once
// Metin2Proto — GDExtension node exposing item_proto / mob_proto to GDScript.
//
// var proto = Metin2Proto.new()
// proto.load_item_proto("<assets>/locale/locale/en/item_proto")
// var d := proto.item(19) # { vnum, name, locale_name, type, sub_type, ... }
//
// Used by the P2 inventory/equipment windows for names / types / tooltips and by
// the equip->model path.
#include <godot_cpp/classes/node.hpp>
#include <godot_cpp/variant/array.hpp>
#include <godot_cpp/variant/dictionary.hpp>
#include <godot_cpp/variant/string.hpp>
#include <cstdint>
#include <unordered_map>
#include "proto.h"
namespace mtgodot {
class Metin2Proto : public godot::Node {
GDCLASS(Metin2Proto, godot::Node)
public:
bool load_item_proto(const godot::String &path);
bool load_mob_proto(const godot::String &path);
godot::Dictionary item(int vnum) const;
godot::Dictionary mob(int vnum) const;
int item_count() const { return (int)m_item.elements; }
int mob_count() const { return (int)m_mob.elements; }
godot::String get_last_error() const { return last_error; }
protected:
static void _bind_methods();
private:
mtproto::Proto m_item;
mtproto::Proto m_mob;
std::unordered_map<uint32_t, uint32_t> m_item_ix; // vnum -> record index
std::unordered_map<uint32_t, uint32_t> m_mob_ix;
godot::String last_error;
};
} // namespace mtgodot
+36 -1
View File
@@ -4,21 +4,56 @@
#include <godot_cpp/core/defs.hpp> #include <godot_cpp/core/defs.hpp>
#include <godot_cpp/godot.hpp> #include <godot_cpp/godot.hpp>
#include "asset_io.h"
#include "metin2_anim.h" #include "metin2_anim.h"
#include "metin2_model.h" #include "metin2_model.h"
#include "metin2_world.h"
#include "m2_material.h"
#include "net/m2_client.h"
#include "proto/proto_node.h"
#include "static_object.h"
#include "terrain_splat.h"
#include "tree_placeholder.h"
#include "water_builder.h"
#include <m2_tokvec.h> // fmt::set_file_reader
#include <string>
using namespace godot; using namespace godot;
// formats/ reads every asset (maps, .msenv, .msm/.msa via textscript, .spt, …)
// through fmt::read_file. Route it through godot::FileAccess so it works from
// res:// (the PCK) on the read-only iOS/Android bundles as well as loose dev files.
static bool mt_fmt_read_file(const std::string &path, std::string &out) {
PackedByteArray b = mtgodot::read_file(String::utf8(path.c_str()));
if (b.is_empty()) {
return false;
}
out.assign(reinterpret_cast<const char *>(b.ptr()), (size_t)b.size());
return true;
}
void initialize_mtgodot_module(ModuleInitializationLevel p_level) { void initialize_mtgodot_module(ModuleInitializationLevel p_level) {
if (p_level != MODULE_INITIALIZATION_LEVEL_SCENE) { if (p_level != MODULE_INITIALIZATION_LEVEL_SCENE) {
return; return;
} }
fmt::set_file_reader(&mt_fmt_read_file);
GDREGISTER_CLASS(mtgodot::Metin2Model); GDREGISTER_CLASS(mtgodot::Metin2Model);
GDREGISTER_CLASS(mtgodot::Metin2AnimPlayer); GDREGISTER_CLASS(mtgodot::Metin2AnimPlayer);
GDREGISTER_CLASS(mtgodot::Metin2World);
GDREGISTER_CLASS(mtgodot::M2Client);
GDREGISTER_CLASS(mtgodot::Metin2Proto);
} }
void uninitialize_mtgodot_module(ModuleInitializationLevel p_level) { void uninitialize_mtgodot_module(ModuleInitializationLevel p_level) {
(void)p_level; if (p_level == MODULE_INITIALIZATION_LEVEL_SCENE) {
fmt::set_file_reader(nullptr);
mtgodot::cleanup_material_shaders();
mtgodot::cleanup_terrain_shader();
mtgodot::cleanup_tree_shader();
mtgodot::cleanup_static_object_cache();
mtgodot::cleanup_water_shader();
}
} }
extern "C" { extern "C" {
+155
View File
@@ -0,0 +1,155 @@
#include "static_object.h"
#include "asset_io.h"
#include "dxt.h"
#include "gr2_bridge.h"
#include "texture_util.h"
#include <godot_cpp/classes/image.hpp>
#include <godot_cpp/classes/image_texture.hpp>
#include <godot_cpp/classes/standard_material3d.hpp>
#include <godot_cpp/variant/packed_byte_array.hpp>
#include <godot_cpp/variant/utility_functions.hpp>
#include <asset_resolver.h>
#include <gr2/gr2.h>
#include <unordered_map>
using namespace godot;
namespace mtgodot {
namespace {
std::unordered_map<std::string, Ref<ImageTexture>> g_dds_cache;
// Building albedo (sRGB colour) -> mobile-ASTC-eligible via make_color_texture
// (no-op on desktop). mipmaps=false keeps the desktop result byte-identical to
// the old path (buildings had no mip chain). §F4.
Ref<ImageTexture> decode_dds_cached(const std::string &real_path) {
auto &cache = g_dds_cache;
auto it = cache.find(real_path);
if (it != cache.end())
return it->second;
Ref<ImageTexture> tex;
mtgodot::Image d = mtgodot::dds_from_file(godot::String(real_path.c_str()));
if (d.ok()) {
tex = mtgodot::make_color_texture(d.w, d.h, d.rgba.data(), d.rgba.size(),
/*mipmaps=*/false);
}
cache.emplace(real_path, tex);
return tex;
}
Ref<StandardMaterial3D> material_for(const std::string &tex_name, bool alpha_blend, bool two_sided,
const fmt::AssetResolver &res) {
Ref<StandardMaterial3D> mat;
mat.instantiate();
mat->set_albedo(Color(0.7f, 0.7f, 0.7f));
mat->set_roughness(1.0f);
mat->set_texture_filter(StandardMaterial3D::TEXTURE_FILTER_LINEAR_WITH_MIPMAPS_ANISOTROPIC);
mat->set_cull_mode(StandardMaterial3D::CULL_BACK);
String t = String(tex_name.c_str()).to_lower();
bool kw_alpha = t.find("leaf") != -1 || t.find("grass") != -1 || t.find("fence") != -1 ||
t.find("net") != -1 || t.find("ivy") != -1 || t.find("tree") != -1 ||
t.find("branch") != -1;
if (alpha_blend) {
// EterGrnLib TYPE_BLEND_PNT:真 alpha 混合(第 2 map 作 opacity
mat->set_transparency(StandardMaterial3D::TRANSPARENCY_ALPHA);
mat->set_cull_mode(StandardMaterial3D::CULL_DISABLED);
} else if (kw_alpha) {
mat->set_transparency(StandardMaterial3D::TRANSPARENCY_ALPHA_SCISSOR);
mat->set_alpha_scissor_threshold(0.5f);
mat->set_cull_mode(StandardMaterial3D::CULL_DISABLED);
}
if (two_sided)
mat->set_cull_mode(StandardMaterial3D::CULL_DISABLED);
if (tex_name.empty())
return mat;
std::string rp = res.resolve(tex_name, nullptr);
if (rp.empty()) {
// gr2 里常是 .dds;有时资产用别的大小写 / 扩展。先只试原名。
return mat;
}
Ref<ImageTexture> tex = decode_dds_cached(rp);
if (tex.is_valid()) {
mat->set_texture(StandardMaterial3D::TEXTURE_ALBEDO, tex);
mat->set_albedo(Color(1, 1, 1));
}
return mat;
}
} // namespace
Ref<godot::ArrayMesh> get_static_mesh(const std::string &real_gr2_path,
const fmt::AssetResolver &res, StaticMeshCache &cache) {
auto it = cache.by_path.find(real_gr2_path);
if (it != cache.by_path.end())
return it->second;
Ref<godot::ArrayMesh> result; // invalid until success
gr2::LoadError err;
auto loaded = mtgodot::gr2_from_file(godot::String(real_gr2_path.c_str()), &err);
if (!loaded) {
++cache.failed;
UtilityFunctions::push_warning(String("[static] gr2 load failed: ") +
real_gr2_path.c_str() + " (" + err.message.c_str() + ")");
cache.by_path.emplace(real_gr2_path, result);
return result;
}
const gr2::FileInfo &fi = loaded->file_info();
// 贴图名 -> 渲染态(来自 libgr2 dump_materials 的名字/map 推断)
std::vector<gr2::MaterialInfo> mats = gr2::dump_materials(*loaded);
std::map<std::string, std::pair<bool, bool>> tex_state; // lower(tex) -> {alpha, two_sided}
for (const auto &m : mats) {
std::string k;
for (char c : m.diffuse_texture)
k += (char)std::tolower((unsigned char)c);
if (!k.empty())
tex_state[k] = {m.alpha_blend, m.two_sided};
}
std::vector<mtgodot::RenderPart> parts = mtgodot::build_parts(fi);
AABB bounds;
Ref<godot::ArrayMesh> mesh = mtgodot::build_mesh(fi, parts, /*flip_winding=*/false, bounds);
if (mesh.is_valid() && mesh->get_surface_count() > 0) {
for (int s = 0; s < mesh->get_surface_count() && s < (int)parts.size(); ++s) {
const mtgodot::RenderPart &rp = parts[s];
std::string tex;
if (rp.mesh >= 0 && rp.mesh < (int)fi.meshes.size()) {
const auto &mt = fi.meshes[rp.mesh].material_textures;
if (rp.mat_index >= 0 && rp.mat_index < (int)mt.size())
tex = mt[rp.mat_index];
else if (!mt.empty())
tex = mt[0];
}
bool alpha = false, two = false;
{
std::string k;
for (char c : tex)
k += (char)std::tolower((unsigned char)c);
auto f = tex_state.find(k);
if (f != tex_state.end()) {
alpha = f->second.first;
two = f->second.second;
}
}
mesh->surface_set_material(s, material_for(tex, alpha, two, res));
}
result = mesh;
++cache.loaded;
} else {
++cache.failed;
}
cache.by_path.emplace(real_gr2_path, result);
return result;
}
void cleanup_static_object_cache() { g_dds_cache.clear(); }
} // namespace mtgodot
+35
View File
@@ -0,0 +1,35 @@
#pragma once
#include <godot_cpp/classes/array_mesh.hpp>
#include <godot_cpp/classes/ref.hpp>
#include <map>
#include <string>
namespace fmt {
struct AssetResolver;
}
namespace mtgodot {
// W3 —— 静态(无骨骼)GR2 对象。用 gr2_bridge 的 build_parts/build_mesh
// 逐 surface 从 gr2 material binding 取贴图(走 AssetResolver 定位 + dxt 解码),
// 不建 Skeleton3D、不逐帧蒙皮。Building / DungeonBlock 用。SHINSOO §9-W3。
//
// SHINSOO §9-W3「Metin2StaticModel 新节点 vs 拆 Metin2Model」的决策:
// 取轻量方案 —— 一个自由函数产出带材质的共享 ArrayMesh,调用方(Metin2World
// 直接挂到 MeshInstance3D,跨实例共享同一份 mesh。
struct StaticMeshCache {
std::map<std::string, godot::Ref<godot::ArrayMesh>> by_path;
int loaded = 0, failed = 0;
};
// real_gr2_path = AssetResolver 解析后的真实路径。返回共享 ArrayMesh(含材质)。
// 失败返回 invalid Ref。
godot::Ref<godot::ArrayMesh> get_static_mesh(const std::string &real_gr2_path,
const fmt::AssetResolver &res, StaticMeshCache &cache);
// 退出时清缓存的 Ref<Image>(别拖到 __cxa_finalize)。
void cleanup_static_object_cache();
} // namespace mtgodot
+227
View File
@@ -0,0 +1,227 @@
#include "terrain_splat.h"
#include "asset_io.h"
#include "dxt.h"
#include <godot_cpp/classes/image.hpp>
#include <godot_cpp/classes/image_texture.hpp>
#include <godot_cpp/classes/shader.hpp>
#include <godot_cpp/classes/texture2d_array.hpp>
#include <godot_cpp/variant/color.hpp>
#include <godot_cpp/variant/packed_byte_array.hpp>
#include <godot_cpp/variant/typed_array.hpp>
#include <asset_resolver.h>
#include <algorithm>
#include <unordered_map>
using namespace godot;
namespace mtgodot {
// 最多 16 层:A1 单区块实测最多 12 个活动图层(`000004`/`003004`),旧的 8 层上限会静默丢层。
static const int MAX_LAYERS = 16;
namespace {
const char *SRC_TERRAIN = R"(shader_type spatial;
render_mode diffuse_lambert, specular_disabled, cull_back;
uniform sampler2DArray layers : source_color, filter_linear_mipmap_anisotropic, repeat_enable;
uniform sampler2D weights0 : filter_linear; // RGBA = layer 0..3 alpha
uniform sampler2D weights1 : filter_linear; // RGBA = layer 4..7 alpha
uniform sampler2D weights2 : filter_linear; // RGBA = layer 8..11 alpha
uniform sampler2D weights3 : filter_linear; // RGBA = layer 12..15 alpha
uniform vec4 layer_uv[16]; // xy = 每区块平铺频率 (8*Scale)zw = offset
uniform int layer_count = 0;
uniform sampler2D shadowmap : source_color, filter_linear;
uniform bool use_shadowmap = false;
void fragment() {
vec3 col = vec3(0.32, 0.30, 0.24);
vec4 w[4];
w[0] = texture(weights0, UV);
w[1] = texture(weights1, UV);
w[2] = texture(weights2, UV);
w[3] = texture(weights3, UV);
for (int i = 0; i < 16; i++) {
if (i >= layer_count) { break; }
float wi = w[i >> 2][i & 3];
if (wi <= 0.003) { continue; }
vec2 tuv = UV * layer_uv[i].xy + layer_uv[i].zw;
vec3 lc = texture(layers, vec3(tuv, float(i))).rgb;
col = mix(col, lc, wi);
}
if (use_shadowmap) {
col *= texture(shadowmap, UV).rgb;
}
ALBEDO = col;
ROUGHNESS = 1.0;
}
)";
Ref<Shader> g_terrain_shader;
Ref<Shader> terrain_shader() {
if (g_terrain_shader.is_null()) {
g_terrain_shader.instantiate();
g_terrain_shader->set_code(SRC_TERRAIN);
}
return g_terrain_shader;
}
// DDS -> RGBA8 Imageresize 到 size×size。缓存(非函数静态 —— 见 cleanup)。
std::unordered_map<std::string, Ref<godot::Image>> g_layer_cache;
Ref<godot::Image> layer_image(const std::string &real_path, int size) {
auto &cache = g_layer_cache;
std::string key = real_path + "@" + std::to_string(size);
auto it = cache.find(key);
if (it != cache.end())
return it->second;
Ref<godot::Image> out;
mtgodot::Image d = mtgodot::dds_from_file(godot::String(real_path.c_str()));
if (d.ok()) {
PackedByteArray b;
b.resize((int64_t)d.rgba.size());
std::copy(d.rgba.begin(), d.rgba.end(), b.ptrw());
out = godot::Image::create_from_data(d.w, d.h, false, godot::Image::FORMAT_RGBA8, b);
if (out.is_valid() && (d.w != size || d.h != size))
out->resize(size, size, godot::Image::INTERPOLATE_BILINEAR);
if (out.is_valid())
out->generate_mipmaps();
}
cache.emplace(key, out);
return out;
}
// SplatLayer.alpha (258²) -> 256² 的某个通道
void pack_channel(uint8_t *dst /*256*256*4*/, int ch, const std::vector<uint8_t> &alpha258) {
const int S = fmt::SPLAT_RAW_XY; // 258
for (int y = 0; y < 256; ++y)
for (int x = 0; x < 256; ++x)
dst[(y * 256 + x) * 4 + ch] = alpha258[size_t(y + 1) * S + (x + 1)];
}
Ref<ImageTexture> weight_tex(const std::vector<const fmt::SplatLayer *> &four) {
std::vector<uint8_t> buf(size_t(256) * 256 * 4, 0);
for (int c = 0; c < 4 && c < (int)four.size(); ++c)
if (four[c])
pack_channel(buf.data(), c, four[c]->alpha);
PackedByteArray b;
b.resize((int64_t)buf.size());
std::copy(buf.begin(), buf.end(), b.ptrw());
Ref<godot::Image> img =
godot::Image::create_from_data(256, 256, false, godot::Image::FORMAT_RGBA8, b);
return ImageTexture::create_from_image(img);
}
} // namespace
void cleanup_terrain_shader() {
g_terrain_shader.unref();
g_layer_cache.clear(); // 释放缓存的 Ref<Image>,别拖到 __cxa_finalize(那时引擎已析构)
}
Ref<ShaderMaterial> build_chunk_terrain_material(const fmt::SplatSet &splat,
const fmt::TextureSet &tset, const fmt::AssetResolver &res,
const String &shadowmap_path) {
// Texture2DArray 要求各 slice 同尺寸 —— 取用到的图层里的最大源边长(上限 1024),
// 只放大不缩小最大源,避免把 512² 地表贴图硬降采样(PARITY-GAP §3.4)。
int src_max = 256;
for (const auto &L : splat.layers) {
if (L.layer >= 1 && L.layer <= (int)tset.layers.size()) {
std::string rp = res.resolve(tset.layers[L.layer - 1].texture, nullptr);
if (rp.empty())
continue;
mtgodot::Image d = mtgodot::dds_from_file(godot::String(rp.c_str()));
if (d.ok())
src_max = std::max<int>(src_max, std::max<int>(d.w, d.h));
}
}
const int LSIZE = std::min(1024, src_max);
int n = std::min<int>(MAX_LAYERS, (int)splat.layers.size());
if (n == 0)
return Ref<ShaderMaterial>();
// 颜色数组
TypedArray<godot::Image> imgs;
std::vector<Color> uvparm(MAX_LAYERS, Color(40, 40, 0, 0));
Ref<godot::Image> fallback;
{
PackedByteArray b;
b.resize(LSIZE * LSIZE * 4);
for (int i = 0; i < LSIZE * LSIZE * 4; i += 4) {
b[i] = 90;
b[i + 1] = 110;
b[i + 2] = 70;
b[i + 3] = 255;
}
fallback = godot::Image::create_from_data(LSIZE, LSIZE, false, godot::Image::FORMAT_RGBA8, b);
fallback->generate_mipmaps();
}
for (int i = 0; i < n; ++i) {
const fmt::SplatLayer &L = splat.layers[i];
Ref<godot::Image> img;
if (L.layer >= 1 && L.layer <= (int)tset.layers.size()) {
const fmt::TextureLayer &tl = tset.layers[L.layer - 1];
std::string rp = res.resolve(tl.texture, nullptr);
if (!rp.empty())
img = layer_image(rp, LSIZE);
// 原客户端 TextureSet.cpp:185u' = (TexCoordBase*UScale)*vtx_cm + UOffset
// TexCoordBase = 1/(PATCH_XSIZE*CELLSCALE) = 1/3200;区块归一化 UV -> 平铺频率 = 8*Scale。
float us = tl.u_scale > 0.01f ? tl.u_scale : 1.0f;
float vs = tl.v_scale > 0.01f ? tl.v_scale : 1.0f;
uvparm[i] = Color(8.0f * us, -8.0f * vs, tl.u_offset, -tl.v_offset);
}
imgs.push_back(img.is_valid() ? img : fallback);
}
Ref<Texture2DArray> arr;
arr.instantiate();
arr->create_from_images(imgs);
// 权重贴图:ceil(n/4) 张 RGBA8(每通道一层 alpha
Ref<ImageTexture> wtex[4];
for (int g = 0; g < 4; ++g) {
std::vector<const fmt::SplatLayer *> grp(4, nullptr);
for (int k = 0; k < 4; ++k) {
int li = g * 4 + k;
if (li < n)
grp[k] = &splat.layers[li];
}
wtex[g] = weight_tex(grp);
}
Ref<ShaderMaterial> mat;
mat.instantiate();
mat->set_shader(terrain_shader());
mat->set_shader_parameter("layers", arr);
mat->set_shader_parameter("weights0", wtex[0]);
mat->set_shader_parameter("weights1", wtex[1]);
mat->set_shader_parameter("weights2", wtex[2]);
mat->set_shader_parameter("weights3", wtex[3]);
mat->set_shader_parameter("layer_count", n);
{
Array uva;
for (int i = 0; i < MAX_LAYERS; ++i)
uva.push_back(Plane(uvparm[i].r, uvparm[i].g, uvparm[i].b, uvparm[i].a));
mat->set_shader_parameter("layer_uv", uva);
}
if (!shadowmap_path.is_empty()) {
mtgodot::Image sm = mtgodot::dds_from_file(shadowmap_path);
if (sm.ok()) {
PackedByteArray b;
b.resize((int64_t)sm.rgba.size());
std::copy(sm.rgba.begin(), sm.rgba.end(), b.ptrw());
Ref<godot::Image> smi = godot::Image::create_from_data(
sm.w, sm.h, false, godot::Image::FORMAT_RGBA8, b);
mat->set_shader_parameter("shadowmap", ImageTexture::create_from_image(smi));
mat->set_shader_parameter("use_shadowmap", true);
}
}
return mat;
}
} // namespace mtgodot
+32
View File
@@ -0,0 +1,32 @@
#pragma once
#include <godot_cpp/classes/image.hpp>
#include <godot_cpp/classes/ref.hpp>
#include <godot_cpp/classes/shader_material.hpp>
#include <godot_cpp/variant/string.hpp>
#include <splat.h>
#include <texture_set.h>
namespace fmt {
struct AssetResolver;
}
namespace mtgodot {
// W2 —— 真正的多图层地表材质。SHINSOO §9-W2。
// - 每图层的 258→256 alpha 打进 RGBA8 权重贴图(≤2 张 = ≤8 图层)
// - 每图层的颜色 DDS 解码 + resize 256²,堆成 Texture2DArray
// - ShaderMaterial:逐图层按权重 mixUV 按 TextureLayer.u_scale/offset 平铺
// - shadowmap.dds 作为 albedo 乘法项(有则)
// 光照交给 Godot 内置 DirectionalLightW5 由 .msenv 驱动)。
godot::Ref<godot::ShaderMaterial> build_chunk_terrain_material(
const fmt::SplatSet &splat,
const fmt::TextureSet &tset,
const fmt::AssetResolver &res,
const godot::String &shadowmap_path);
// 清理 function-static 的 terrain shader(退出时调,避免 "shader never freed")。
void cleanup_terrain_shader();
} // namespace mtgodot
+76
View File
@@ -0,0 +1,76 @@
#include "texture_util.h"
#include <cstdlib>
#include <cstring>
#include <godot_cpp/classes/os.hpp>
#include <godot_cpp/core/class_db.hpp>
#include <godot_cpp/variant/packed_byte_array.hpp>
#include <godot_cpp/variant/utility_functions.hpp>
using namespace godot;
namespace mtgodot {
namespace {
int g_state = -1; // -1 = uninit, 0 = off, 1 = on
bool g_warned = false;
void lazy_init() {
if (g_state != -1) {
return;
}
if (const char *e = std::getenv("MTGODOT_TEXCOMP")) {
g_state = (e[0] == '1') ? 1 : 0;
return;
}
// No explicit override: on for mobile targets, off for desktop (keeps the
// Phase-1 parity path byte-identical).
OS *os = OS::get_singleton();
g_state = (os && os->has_feature("mobile")) ? 1 : 0;
}
} // namespace
bool texcomp_enabled() {
lazy_init();
return g_state == 1;
}
void texcomp_set_enabled(bool on) {
g_state = on ? 1 : 0;
}
Ref<ImageTexture> make_color_texture(int w, int h, const uint8_t *rgba, size_t len,
bool mipmaps, TexUse use) {
if (w <= 0 || h <= 0 || rgba == nullptr || len < size_t(w) * size_t(h) * 4) {
return Ref<ImageTexture>();
}
PackedByteArray bytes;
bytes.resize(int64_t(w) * h * 4);
std::memcpy(bytes.ptrw(), rgba, size_t(w) * size_t(h) * 4);
Ref<Image> img = Image::create_from_data(w, h, false, Image::FORMAT_RGBA8, bytes);
if (img.is_null()) {
return Ref<ImageTexture>();
}
if (mipmaps) {
img->generate_mipmaps();
}
if (use == TexUse::COLOR && texcomp_enabled()) {
// ASTC 8x8: ~2 bpp vs 32 for RGBA8. GENERIC source hint (sRGB colour).
Error err = img->compress(Image::COMPRESS_ASTC, Image::COMPRESS_SOURCE_GENERIC,
Image::ASTC_FORMAT_8x8);
if (err != OK && !g_warned) {
g_warned = true;
UtilityFunctions::push_warning(
"mtgodot: runtime ASTC compression unavailable, using RGBA8");
}
}
return ImageTexture::create_from_image(img);
}
} // namespace mtgodot
+34
View File
@@ -0,0 +1,34 @@
// texture_util —— 统一的「RGBA8 字节 -> ImageTexture」出口,可选 GPU 压缩。
//
// 桌面默认关(与旧路径逐字节一致:create_from_data + generate_mipmaps +
// create_from_image)。移动端(OS.has_feature("mobile"))或 MTGODOT_TEXCOMP=1
// 时,对**颜色贴图**做运行时 ASTC 8x8 压缩,省 ~6–8x 显存/带宽;编码失败自动
// 回退 RGBA8。控制图 / 阴影图 / splat / 法线数据图不要走这个(用 RGBA8)。
//
// BACKLOG F4 / docs/PLATFORMS.md。
#pragma once
#include <cstddef>
#include <cstdint>
#include <godot_cpp/classes/image_texture.hpp>
#include <godot_cpp/classes/image.hpp>
#include <godot_cpp/classes/ref.hpp>
namespace mtgodot {
// 颜色贴图(sRGB 视觉内容,可压)。法线/数据图另说,暂不压。
enum class TexUse { COLOR };
// 运行时压缩开关。首次调用惰性初始化:MTGODOT_TEXCOMP 环境变量优先
// "1"/"0"),否则 OS.has_feature("mobile")。也可显式覆盖。
bool texcomp_enabled();
void texcomp_set_enabled(bool on);
// w*h*4 的 level-0 RGBA8 -> ImageTexture。mipmaps=true 时先生成 mip 链
// (ASTC 压缩前必须)。压缩仅在 texcomp_enabled() && use==COLOR 时发生。
godot::Ref<godot::ImageTexture> make_color_texture(
int w, int h, const uint8_t *rgba, size_t len, bool mipmaps,
TexUse use = TexUse::COLOR);
} // namespace mtgodot
+491
View File
@@ -0,0 +1,491 @@
#include "tree_placeholder.h"
#include "asset_io.h"
#include "dxt.h"
#include "texture_util.h"
#include <asset_resolver.h>
#include <spt.h>
#include <godot_cpp/classes/image.hpp>
#include <godot_cpp/classes/image_texture.hpp>
#include <godot_cpp/classes/shader.hpp>
#include <godot_cpp/classes/shader_material.hpp>
#include <godot_cpp/classes/standard_material3d.hpp>
#include <godot_cpp/variant/packed_byte_array.hpp>
#include <godot_cpp/variant/packed_int32_array.hpp>
#include <godot_cpp/variant/packed_vector2_array.hpp>
#include <godot_cpp/variant/packed_vector3_array.hpp>
#include <algorithm>
#include <cctype>
#include <cmath>
#include <cstdint>
#include <unordered_map>
#include <vector>
using namespace godot;
namespace mtgodot {
namespace {
constexpr float kPI = 3.14159265358979323846f;
constexpr float kTAU = 2.0f * kPI;
// SpeedTree 2 的叶片是中心点 + leaf-cluster table,在顶点 shader 中展开。
// proxy 已在 CPU 侧把叶簇展开成 card;这里只保留 alpha-test 和轻微、按实例错相的风摆。
const char *SRC_LEAF = R"(shader_type spatial;
render_mode cull_disabled, diffuse_lambert, specular_disabled, depth_prepass_alpha;
uniform sampler2D leaf_tex : source_color, filter_linear_mipmap_anisotropic;
uniform float wind_strength = 1.0;
void vertex() {
vec3 wp = (MODEL_MATRIX * vec4(0.0, 0.0, 0.0, 1.0)).xyz;
float ph = wp.x * 0.11 + wp.z * 0.13;
float h = max(VERTEX.y, 0.0);
VERTEX.x += sin(TIME * 1.3 + ph) * 0.025 * wind_strength * h;
VERTEX.z += cos(TIME * 1.05 + ph) * 0.018 * wind_strength * h;
}
void fragment() {
vec4 c = texture(leaf_tex, UV);
if (c.a < 0.38) { discard; }
ALBEDO = c.rgb;
ROUGHNESS = 1.0;
}
)";
Ref<Shader> g_leaf_shader;
Ref<ImageTexture> g_fallback_broadleaf;
Ref<ImageTexture> g_fallback_conifer;
std::unordered_map<std::string, Ref<ImageTexture>> g_tree_texture_cache;
std::unordered_map<std::string, Ref<ArrayMesh>> g_tree_mesh_cache;
Ref<Shader> leaf_shader() {
if (g_leaf_shader.is_null()) {
g_leaf_shader.instantiate();
g_leaf_shader->set_code(SRC_LEAF);
}
return g_leaf_shader;
}
Ref<ImageTexture> fallback_leaf_texture(bool conifer) {
Ref<ImageTexture> &cached = conifer ? g_fallback_conifer : g_fallback_broadleaf;
if (cached.is_valid())
return cached;
const int N = 96;
PackedByteArray b;
b.resize(N * N * 4);
for (int y = 0; y < N; ++y) {
for (int x = 0; x < N; ++x) {
const float u = (x + 0.5f) / N * 2.0f - 1.0f;
const float v = (y + 0.5f) / N * 2.0f - 1.0f;
const float d = std::sqrt(u * u + v * v);
float a = 1.0f - d;
a = a <= 0 ? 0.0f : a * a * (3.0f - 2.0f * a);
const float n = 0.5f + 0.5f * std::sin(x * 0.9f) * std::sin(y * 0.7f);
const float g = conifer ? (0.28f + 0.14f * n) : (0.40f + 0.16f * n);
const float r = conifer ? (0.11f + 0.06f * n) : (0.18f + 0.10f * n);
const float bl = 0.10f + 0.06f * n;
const int o = (y * N + x) * 4;
b[o + 0] = uint8_t(std::min(255.0f, r * 255.0f));
b[o + 1] = uint8_t(std::min(255.0f, g * 255.0f));
b[o + 2] = uint8_t(std::min(255.0f, bl * 255.0f));
b[o + 3] = uint8_t(std::min(255.0f, a * 255.0f));
}
}
Ref<godot::Image> img =
godot::Image::create_from_data(N, N, false, godot::Image::FORMAT_RGBA8, b);
img->generate_mipmaps();
cached = ImageTexture::create_from_image(img);
return cached;
}
Ref<ImageTexture> load_dds_texture(const std::string &path) {
if (path.empty())
return Ref<ImageTexture>();
auto found = g_tree_texture_cache.find(path);
if (found != g_tree_texture_cache.end())
return found->second;
Ref<ImageTexture> result;
mtgodot::Image d = mtgodot::dds_from_file(godot::String(path.c_str()));
if (d.ok()) {
// Bark / leaf-composite albedo (sRGB) -> mobile ASTC via
// make_color_texture (no-op on desktop; keeps mipmaps). §F4.
result = mtgodot::make_color_texture(d.w, d.h, d.rgba.data(), d.rgba.size(),
/*mipmaps=*/true);
}
g_tree_texture_cache.emplace(path, result);
return result;
}
std::string lower(std::string s) {
std::transform(s.begin(), s.end(), s.begin(),
[](unsigned char c) { return (char)std::tolower(c); });
return s;
}
std::string basename(std::string path) {
for (char &c : path)
if (c == '\\') c = '/';
const size_t slash = path.find_last_of('/');
return slash == std::string::npos ? path : path.substr(slash + 1);
}
std::string as_dds(std::string path) {
const size_t dot = path.find_last_of('.');
if (dot != std::string::npos)
path.resize(dot);
return path + ".dds";
}
std::string resolve_sibling(const std::string &treefile, const std::string &texture,
const fmt::AssetResolver &resolver) {
if (texture.empty())
return "";
std::string parent = fmt::AssetResolver::normalize(treefile);
const size_t slash = parent.find_last_of('/');
if (slash != std::string::npos)
parent.resize(slash);
else
parent.clear();
const std::string name = as_dds(basename(texture));
return resolver.resolve(parent.empty() ? name : parent + "/" + name, nullptr);
}
struct TreeTextures {
Ref<ImageTexture> bark;
Ref<ImageTexture> composite;
std::string composite_name;
};
TreeTextures resolve_tree_textures(const std::string &treefile,
const fmt::AssetResolver &resolver) {
TreeTextures out;
const std::string spt_path = resolver.resolve(treefile, nullptr);
if (spt_path.empty())
return out;
fmt::SptInfo info;
if (!fmt::sniff_spt_file(spt_path, info))
return out;
std::string branch;
for (const std::string &ref : info.texture_refs) {
if (lower(ref).find("bark") != std::string::npos) {
branch = ref;
break;
}
}
if (branch.empty() && !info.texture_refs.empty())
branch = info.texture_refs.front();
out.bark = load_dds_texture(resolve_sibling(treefile, branch, resolver));
out.composite_name = info.composite_texture;
out.composite = load_dds_texture(
resolve_sibling(treefile, info.composite_texture, resolver));
return out;
}
struct UVRect {
float u0 = 0, v0 = 0, u1 = 1, v1 = 1;
};
std::vector<UVRect> foliage_rects(const std::string &species,
const std::string &composite, bool atlas) {
if (!atlas)
return {{0, 0, 1, 1}};
// SPT leaf-cluster UV 尚未导出;这些区域只选择各 composite atlas 中的真实叶簇,
// 不声称复原了具体树种的原始 UV。Windows exporter 接入后删除这组 proxy 布局。
const std::string s = lower(species);
const std::string c = lower(composite);
const bool fall = s.find("fall") != std::string::npos;
const bool winter = s.find("winter") != std::string::npos;
if (c.find("b1") != std::string::npos) {
if (fall) return {{0.00f, 0.05f, 0.25f, 0.25f}, {0.25f, 0.25f, 0.50f, 0.50f}};
return {{0.25f, 0.02f, 0.50f, 0.23f}, {0.25f, 0.18f, 0.50f, 0.36f},
{0.00f, 0.27f, 0.27f, 0.49f}};
}
if (c.find("b2") != std::string::npos) {
if (fall) return {{0.00f, 0.00f, 0.25f, 0.25f}, {0.25f, 0.25f, 0.50f, 0.50f}};
return {{0.50f, 0.38f, 0.75f, 0.63f}, {0.50f, 0.63f, 0.75f, 0.88f},
{0.00f, 0.38f, 0.25f, 0.62f}};
}
if (c.find("b3") != std::string::npos) {
if (fall) return {{0.25f, 0.25f, 0.50f, 0.50f}};
return {{0.00f, 0.25f, 0.25f, 0.50f}, {0.00f, 0.50f, 0.25f, 0.75f},
{0.25f, 0.50f, 0.50f, 0.75f}};
}
if (c.find("n1") != std::string::npos) {
if (winter) return {{0.00f, 0.36f, 0.50f, 0.58f}, {0.25f, 0.55f, 0.52f, 0.75f}};
return {{0.00f, 0.72f, 0.28f, 0.96f}, {0.25f, 0.74f, 0.53f, 0.97f}};
}
if (c.find("n2") != std::string::npos) {
return {{0.00f, 0.48f, 0.27f, 0.75f}, {0.26f, 0.73f, 0.58f, 1.00f},
{0.75f, 0.48f, 1.00f, 0.80f}};
}
return {{0, 0, 1, 1}};
}
struct Buf {
PackedVector3Array v, n;
PackedVector2Array uv;
PackedInt32Array idx;
void quad(const Vector3 &a, const Vector3 &b, const Vector3 &c, const Vector3 &d,
const UVRect &r, bool flip_u = false) {
const int base = v.size();
const Vector3 nn = (b - a).cross(d - a).normalized();
v.push_back(a);
v.push_back(b);
v.push_back(c);
v.push_back(d);
for (int i = 0; i < 4; ++i)
n.push_back(nn);
const float l = flip_u ? r.u1 : r.u0;
const float rr = flip_u ? r.u0 : r.u1;
uv.push_back(Vector2(l, r.v1));
uv.push_back(Vector2(rr, r.v1));
uv.push_back(Vector2(rr, r.v0));
uv.push_back(Vector2(l, r.v0));
idx.push_back(base);
idx.push_back(base + 1);
idx.push_back(base + 2);
idx.push_back(base);
idx.push_back(base + 2);
idx.push_back(base + 3);
}
void tube_quad(const Vector3 &b0, const Vector3 &b1, const Vector3 &t1,
const Vector3 &t0, const Vector3 &n0, const Vector3 &n1,
float u0, float u1, float v0, float v1) {
const int base = v.size();
v.push_back(b0);
v.push_back(b1);
v.push_back(t1);
v.push_back(t0);
n.push_back(n0);
n.push_back(n1);
n.push_back(n1);
n.push_back(n0);
uv.push_back(Vector2(u0, v0));
uv.push_back(Vector2(u1, v0));
uv.push_back(Vector2(u1, v1));
uv.push_back(Vector2(u0, v1));
idx.push_back(base);
idx.push_back(base + 2);
idx.push_back(base + 1);
idx.push_back(base);
idx.push_back(base + 3);
idx.push_back(base + 2);
}
Array arrays() const {
Array a;
a.resize(Mesh::ARRAY_MAX);
a[Mesh::ARRAY_VERTEX] = v;
a[Mesh::ARRAY_NORMAL] = n;
a[Mesh::ARRAY_TEX_UV] = uv;
a[Mesh::ARRAY_INDEX] = idx;
return a;
}
};
void tube(Buf &m, const Vector3 &from, const Vector3 &to,
float r0, float r1, int seg, float bark_repeat = 1.0f) {
const Vector3 axis = (to - from).normalized();
if (axis.length_squared() < 0.5f)
return;
const Vector3 helper = std::fabs(axis.y) > 0.9f ? Vector3(1, 0, 0) : Vector3(0, 1, 0);
const Vector3 u = axis.cross(helper).normalized();
const Vector3 w = axis.cross(u).normalized();
for (int i = 0; i < seg; ++i) {
const float a0 = float(i) / seg * kTAU;
const float a1 = float(i + 1) / seg * kTAU;
const Vector3 n0 = u * std::cos(a0) + w * std::sin(a0);
const Vector3 n1 = u * std::cos(a1) + w * std::sin(a1);
m.tube_quad(from + n0 * r0, from + n1 * r0, to + n1 * r1, to + n0 * r1,
n0, n1, float(i) / seg, float(i + 1) / seg, bark_repeat, 0.0f);
}
}
uint32_t hash32(uint32_t s) {
s ^= s >> 16;
s *= 0x7feb352dU;
s ^= s >> 15;
s *= 0x846ca68bU;
s ^= s >> 16;
return s;
}
float hash01(uint32_t s) {
return float(hash32(s) & 0x00FFFFFFU) / float(0x01000000U);
}
uint32_t species_seed(const std::string &s) {
uint32_t h = 2166136261U;
for (unsigned char c : s) {
h ^= c;
h *= 16777619U;
}
return h;
}
bool species_is_palm(const std::string &hint) {
const std::string h = lower(hint);
static const char *kw[] = {"palm", "banana", "aloe", "fern", "joshua"};
for (const char *k : kw)
if (h.find(k) != std::string::npos)
return true;
return false;
}
void add_branches(Buf &wood, const std::string &species, float H, bool conifer, bool palm) {
const float trunk_top = H * (palm ? 0.82f : (conifer ? 0.90f : 0.76f));
const float trunk_r = H * (palm ? 0.028f : 0.035f);
tube(wood, Vector3(0, 0, 0), Vector3(0, trunk_top, 0),
trunk_r * 1.35f, trunk_r * 0.42f, 9, H * 0.22f);
if (palm)
return;
const int count = conifer ? 9 : 8;
const uint32_t seed = species_seed(species);
for (int i = 0; i < count; ++i) {
const float f = (i + 1.0f) / (count + 1.0f);
const float y = H * (conifer ? (0.28f + f * 0.52f) : (0.32f + f * 0.34f));
const float angle = kTAU * (f * 1.6180339f + hash01(seed + i * 17U));
const float len = H * (conifer ? (0.24f * (1.0f - f * 0.55f)) :
(0.18f + 0.08f * hash01(seed + i * 29U)));
const Vector3 from(0, y, 0);
const Vector3 to(std::cos(angle) * len,
y + H * (conifer ? 0.06f : (0.10f + 0.06f * hash01(seed + i * 31U))),
std::sin(angle) * len);
tube(wood, from, to, trunk_r * (0.55f - 0.20f * f), trunk_r * 0.12f, 6,
H * 0.08f);
if (!conifer && (i % 2 == 0)) {
const float side = angle + (hash01(seed + i * 37U) > 0.5f ? 0.65f : -0.65f);
const Vector3 tip = to + Vector3(std::cos(side), 0.65f, std::sin(side)) * (len * 0.42f);
tube(wood, to, tip, trunk_r * 0.16f, trunk_r * 0.05f, 5, H * 0.04f);
}
}
}
void add_leaf_cards(Buf &leaves, const std::string &species, float H,
bool conifer, bool palm, const std::vector<UVRect> &rects) {
const uint32_t seed = species_seed(species);
const int count = palm ? 16 : (conifer ? 24 : 24);
for (int i = 0; i < count; ++i) {
const float a = kTAU * (float(i) * 0.6180339f + hash01(seed + i * 101U) * 0.15f);
Vector3 center;
float width = 1.0f, height = 1.0f;
if (palm) {
const float radial = H * (0.10f + 0.18f * hash01(seed + i * 103U));
center = Vector3(std::cos(a) * radial, H * (0.78f + 0.12f * hash01(seed + i * 107U)),
std::sin(a) * radial);
width = H * 0.32f;
height = H * 0.18f;
} else if (conifer) {
const float yf = 0.30f + 0.62f * (float(i) + 0.5f) / count;
const float radial = H * 0.23f * (1.0f - yf * 0.70f) *
(0.35f + 0.65f * hash01(seed + i * 109U));
center = Vector3(std::cos(a) * radial, H * yf, std::sin(a) * radial);
width = H * (0.18f + 0.10f * (1.0f - yf));
height = H * 0.18f;
} else {
const float yf = hash01(seed + i * 109U);
const float yn = yf * 2.0f - 1.0f;
const float radial = H * 0.34f * std::sqrt(std::max(0.05f, 1.0f - yn * yn)) *
(0.25f + 0.75f * std::sqrt(hash01(seed + i * 113U)));
center = Vector3(std::cos(a) * radial, H * (0.58f + yf * 0.34f),
std::sin(a) * radial);
width = H * (0.23f + 0.10f * hash01(seed + i * 127U));
height = H * (0.15f + 0.08f * hash01(seed + i * 131U));
}
const Vector3 right(std::cos(a + kPI * 0.5f), 0, std::sin(a + kPI * 0.5f));
const Vector3 up(0, 1, 0);
const UVRect &uv = rects[size_t(i) % rects.size()];
auto card = [&](const Vector3 &r, bool flip) {
leaves.quad(center - r * (width * 0.5f) - up * (height * 0.5f),
center + r * (width * 0.5f) - up * (height * 0.5f),
center + r * (width * 0.5f) + up * (height * 0.5f),
center - r * (width * 0.5f) + up * (height * 0.5f), uv, flip);
};
card(right, (i & 1) != 0);
// 原 SpeedTree leaf cluster 始终面向相机;静态 proxy 用交叉 card 保证任意视角
// 都不会只看到一条边。离线 exporter 接入后由真实 leaf table 替代。
const Vector3 crossed(std::cos(a), 0, std::sin(a));
card(crossed, (i & 1) == 0);
}
}
Ref<ArrayMesh> build_proxy_impl(const std::string &species, float height_m,
const TreeTextures &textures) {
const String hint(species.c_str());
const bool conifer = species_is_conifer(hint);
const bool palm = species_is_palm(species);
const float H = std::max(2.0f, height_m);
const bool atlas = textures.composite.is_valid();
const std::vector<UVRect> rects = foliage_rects(species, textures.composite_name, atlas);
Buf wood, leaves;
add_branches(wood, species, H, conifer, palm);
add_leaf_cards(leaves, species, H, conifer, palm, rects);
Ref<ArrayMesh> mesh;
mesh.instantiate();
mesh->add_surface_from_arrays(Mesh::PRIMITIVE_TRIANGLES, wood.arrays());
mesh->add_surface_from_arrays(Mesh::PRIMITIVE_TRIANGLES, leaves.arrays());
Ref<StandardMaterial3D> bark;
bark.instantiate();
bark->set_albedo(textures.bark.is_valid() ? Color(1, 1, 1) : Color(0.30f, 0.21f, 0.13f));
bark->set_roughness(1.0f);
bark->set_texture_filter(StandardMaterial3D::TEXTURE_FILTER_LINEAR_WITH_MIPMAPS_ANISOTROPIC);
if (textures.bark.is_valid())
bark->set_texture(StandardMaterial3D::TEXTURE_ALBEDO, textures.bark);
mesh->surface_set_material(0, bark);
Ref<ShaderMaterial> leaf;
leaf.instantiate();
leaf->set_shader(leaf_shader());
leaf->set_shader_parameter("leaf_tex",
textures.composite.is_valid() ? textures.composite : fallback_leaf_texture(conifer));
leaf->set_shader_parameter("wind_strength", 1.0f);
mesh->surface_set_material(1, leaf);
return mesh;
}
} // namespace
void cleanup_tree_shader() {
g_tree_mesh_cache.clear();
g_tree_texture_cache.clear();
g_fallback_broadleaf.unref();
g_fallback_conifer.unref();
g_leaf_shader.unref();
}
bool species_is_conifer(const String &hint) {
const String h = hint.to_lower();
static const char *kw[] = {"cedar", "cypress", "pine", "fir", "spruce", "conifer", "juniper",
"christmastree"};
for (const char *k : kw)
if (h.find(k) != -1)
return true;
return false;
}
Ref<ArrayMesh> build_placeholder_tree(const String &species_hint, float height_m) {
TreeTextures empty;
return build_proxy_impl(std::string(species_hint.utf8().get_data()), height_m, empty);
}
Ref<ArrayMesh> get_tree_proxy_mesh(const std::string &treefile,
const fmt::AssetResolver &resolver, float height_m) {
const std::string key = resolver.assets_root + "|" + fmt::AssetResolver::normalize(treefile) +
"#" + std::to_string(height_m);
auto found = g_tree_mesh_cache.find(key);
if (found != g_tree_mesh_cache.end())
return found->second;
const TreeTextures textures = resolve_tree_textures(treefile, resolver);
Ref<ArrayMesh> mesh = build_proxy_impl(treefile, height_m, textures);
g_tree_mesh_cache.emplace(key, mesh);
return mesh;
}
} // namespace mtgodot
+34
View File
@@ -0,0 +1,34 @@
#pragma once
#include <godot_cpp/classes/array_mesh.hpp>
#include <godot_cpp/classes/ref.hpp>
#include <godot_cpp/variant/string.hpp>
#include <string>
namespace fmt {
struct AssetResolver;
}
// W4/R2 —— `.spt` 几何尚未跨平台读取(见 formats/spt.h),运行时使用 tree proxy
// 确定性枝干 + 多组交叉叶簇,并优先采用 .spt 指向的真实 bark/composite DDS。
// 一份共享 mesh / treefile,逐实例只由 MultiMesh 承载原 AreaData 位置。
namespace mtgodot {
// species_hint = treefile 名(判针叶/阔叶)。height_m ≈ 期望树高(米)。
godot::Ref<godot::ArrayMesh> build_placeholder_tree(const godot::String &species_hint,
float height_m = 12.0f);
// R2 tree proxy:保留无专有运行时的限制,但从 .spt 嗅探真实树皮和 composite atlas
// 用确定性的枝干/叶簇 mesh 近似 SpeedTree 的 branch/frond/leaf 分层。结果按 treefile 缓存,
// 可安全用于每树种一个 MultiMesh。若资源解析失败,自动退回纯程序化材质。
godot::Ref<godot::ArrayMesh> get_tree_proxy_mesh(const std::string &treefile,
const fmt::AssetResolver &resolver, float height_m = 12.0f);
// 从 treefile 名猜是否针叶(cedar / cypress / pine / fir / spruce…)。
bool species_is_conifer(const godot::String &species_hint);
// 退出时清 shader、DDS 和 treefile->mesh 缓存。
void cleanup_tree_shader();
} // namespace mtgodot
+199
View File
@@ -0,0 +1,199 @@
#include "water_builder.h"
#include "asset_io.h"
#include "dxt.h"
#include <godot_cpp/classes/image.hpp>
#include <godot_cpp/classes/shader.hpp>
#include <godot_cpp/classes/shader_material.hpp>
#include <godot_cpp/classes/texture2d_array.hpp>
#include <godot_cpp/variant/packed_byte_array.hpp>
#include <godot_cpp/variant/packed_color_array.hpp>
#include <godot_cpp/variant/packed_int32_array.hpp>
#include <godot_cpp/variant/packed_vector2_array.hpp>
#include <godot_cpp/variant/packed_vector3_array.hpp>
#include <godot_cpp/variant/typed_array.hpp>
#include <asset_resolver.h>
#include <m2_coord.h>
#include <terrain_mesh.h> // terrain_height_at
#include <algorithm>
using namespace godot;
namespace mtgodot {
namespace {
// 30 帧序列 + 逐顶点水深 alpha(顶点 COLOR.a) + 轻微高度浮动。
// UV = 世界米;平铺频率在 shader 里按 1/(CELLSCALE*4 cm) = 1/8m。
const char *SRC_WATER = R"(shader_type spatial;
render_mode blend_mix, cull_disabled, depth_draw_never, diffuse_lambert, specular_schlick_ggx;
uniform sampler2DArray frames : source_color, filter_linear_mipmap, repeat_enable;
uniform vec3 tint : source_color = vec3(0.10, 0.22, 0.26);
uniform float uv_per_meter = 0.125; // 1/8m= 原客户端 1/(CELLSCALE*4)
uniform float bob_amp = 0.06; // 高度浮动幅度(米),近似 MapOutdoorWater 0..-15cm
void vertex() {
VERTEX.y += sin(TIME * 0.6 + VERTEX.x * 0.01 + VERTEX.z * 0.013) * bob_amp;
}
void fragment() {
int f = int(mod(TIME * 1000.0 / 70.0, 30.0)); // 70ms/帧
vec2 uv = UV * uv_per_meter;
vec3 tex = texture(frames, vec3(uv, float(f))).rgb;
float fres = pow(1.0 - clamp(dot(normalize(VIEW), NORMAL), 0.0, 1.0), 3.0);
ALBEDO = mix(tint, tex, 0.6) + fres * 0.15;
ALPHA = clamp(COLOR.a + fres * 0.25, 0.12, 0.95);
ROUGHNESS = 0.08;
METALLIC = 0.0;
SPECULAR = 0.6;
}
)";
Ref<Shader> g_water_shader;
Ref<ShaderMaterial> g_water_mat; // 30 帧数组只建一次
Ref<ShaderMaterial> water_material(const fmt::AssetResolver &res) {
if (g_water_mat.is_valid())
return g_water_mat;
if (g_water_shader.is_null()) {
g_water_shader.instantiate();
g_water_shader->set_code(SRC_WATER);
}
Ref<ShaderMaterial> m;
m.instantiate();
m->set_shader(g_water_shader);
// special/water/01..30.dds
TypedArray<godot::Image> imgs;
int W = 0, H = 0;
for (int i = 1; i <= 30; ++i) {
char nm[64];
std::snprintf(nm, sizeof(nm), "d:/ymir work/special/water/%02d.dds", i);
std::string rp = res.resolve(nm, nullptr);
mtgodot::Image d = rp.empty() ? mtgodot::Image{} : mtgodot::dds_from_file(godot::String(rp.c_str()));
if (!d.ok())
break;
if (W == 0) {
W = d.w;
H = d.h;
}
PackedByteArray b;
b.resize((int64_t)d.rgba.size());
std::copy(d.rgba.begin(), d.rgba.end(), b.ptrw());
Ref<godot::Image> img =
godot::Image::create_from_data(d.w, d.h, false, godot::Image::FORMAT_RGBA8, b);
if (img.is_valid() && (d.w != W || d.h != H))
img->resize(W, H, godot::Image::INTERPOLATE_BILINEAR);
if (img.is_valid())
img->generate_mipmaps();
imgs.push_back(img);
}
if (imgs.size() == 30) {
Ref<Texture2DArray> arr;
arr.instantiate();
arr->create_from_images(imgs);
m->set_shader_parameter("frames", arr);
}
g_water_mat = m;
return m;
}
} // namespace
void cleanup_water_shader() {
g_water_shader.unref();
g_water_mat.unref();
}
std::vector<WaterPiece> build_chunk_water(const fmt::WaterMap &wm, const fmt::HeightMap &hm,
int tile_x, int tile_y, double height_scale, const fmt::AssetResolver &res) {
std::vector<WaterPiece> out;
if (wm.layer_count == 0 || wm.ids.size() != size_t(fmt::WATERMAP_XY) * fmt::WATERMAP_XY)
return out;
const int W = fmt::WATERMAP_XY; // 128
const double CELL_M = double(fmt::m2coord::CELLSCALE) * fmt::m2coord::CM_TO_M; // 2m/texel
const double X0 = double(tile_x) * fmt::m2coord::CHUNK_CM * fmt::m2coord::CM_TO_M;
const double Z0 = double(tile_y) * fmt::m2coord::CHUNK_CM * fmt::m2coord::CM_TO_M;
auto depth_alpha = [&](int tex_x, int tex_y, double water_h_cm) -> float {
// 水 texel (tex_x,tex_y) 对应的区块本地 cmtexel = 1 格 = CELLSCALE
double lx = tex_x * double(fmt::m2coord::CELLSCALE);
double ly = tex_y * double(fmt::m2coord::CELLSCALE);
double th = fmt::terrain_height_at(hm, lx, ly, height_scale); // cm
double depth_cm = water_h_cm - th;
float a = float(depth_cm / 60.0); // 60cm 深 -> 接近不透明
return a < 0.12f ? 0.12f : (a > 0.9f ? 0.9f : a);
};
for (int layer = 0; layer < wm.layer_count; ++layer) {
double h_cm = double(layer < (int)wm.heights.size() ? wm.heights[layer] : 0) * height_scale;
float gy = float(h_cm * fmt::m2coord::CM_TO_M);
PackedVector3Array v;
PackedVector3Array n;
PackedVector2Array uv;
PackedColorArray col;
PackedInt32Array idx;
for (int y = 0; y < W; ++y) {
int x = 0;
while (x < W) {
if (wm.ids[y * W + x] != layer) {
++x;
continue;
}
int xs = x;
while (x < W && wm.ids[y * W + x] == layer)
++x;
double x0 = X0 + xs * CELL_M, x1 = X0 + x * CELL_M;
double z0 = Z0 + y * CELL_M, z1 = Z0 + (y + 1) * CELL_M;
int base = v.size();
v.push_back(Vector3(x0, gy, z0));
v.push_back(Vector3(x1, gy, z0));
v.push_back(Vector3(x1, gy, z1));
v.push_back(Vector3(x0, gy, z1));
for (int k = 0; k < 4; ++k)
n.push_back(Vector3(0, 1, 0));
uv.push_back(Vector2(float(x0), float(z0)));
uv.push_back(Vector2(float(x1), float(z0)));
uv.push_back(Vector2(float(x1), float(z1)));
uv.push_back(Vector2(float(x0), float(z1)));
float aLL = depth_alpha(xs, y, h_cm), aLR = depth_alpha(x, y, h_cm);
float aUR = depth_alpha(x, y + 1, h_cm), aUL = depth_alpha(xs, y + 1, h_cm);
col.push_back(Color(1, 1, 1, aLL));
col.push_back(Color(1, 1, 1, aLR));
col.push_back(Color(1, 1, 1, aUR));
col.push_back(Color(1, 1, 1, aUL));
idx.push_back(base);
idx.push_back(base + 2);
idx.push_back(base + 1);
idx.push_back(base);
idx.push_back(base + 3);
idx.push_back(base + 2);
}
}
if (v.is_empty())
continue;
Array arr;
arr.resize(Mesh::ARRAY_MAX);
arr[Mesh::ARRAY_VERTEX] = v;
arr[Mesh::ARRAY_NORMAL] = n;
arr[Mesh::ARRAY_TEX_UV] = uv;
arr[Mesh::ARRAY_COLOR] = col;
arr[Mesh::ARRAY_INDEX] = idx;
Ref<godot::ArrayMesh> mesh;
mesh.instantiate();
mesh->add_surface_from_arrays(Mesh::PRIMITIVE_TRIANGLES, arr);
mesh->surface_set_material(0, water_material(res));
out.push_back({mesh, gy});
}
return out;
}
} // namespace mtgodot
+30
View File
@@ -0,0 +1,30 @@
#pragma once
#include <godot_cpp/classes/array_mesh.hpp>
#include <godot_cpp/classes/ref.hpp>
#include <terrain_files.h>
#include <vector>
namespace fmt {
struct AssetResolver;
}
// W7 / PARITY §4 —— water.wtr -> 每层水面网格(texel 掩膜)+ 共享水材质。
// 水材质用原客户端资产:`special/water/01..30.dds` 30 帧序列(`MapOutdoorWater.cpp:14/43`
// 70ms/帧),UV 平铺频率 = 1/(CELLSCALE*4)(每 4 格一循环),逐顶点水深 alpha,轻微高度浮动。
namespace mtgodot {
struct WaterPiece {
godot::Ref<godot::ArrayMesh> mesh; // Godot 空间,已含区块原点
float godot_y = 0;
};
// hm/height_scale 用来算每个水面顶点下方的地形高度 -> 水深 -> alpha。
std::vector<WaterPiece> build_chunk_water(const fmt::WaterMap &wm, const fmt::HeightMap &hm,
int tile_x, int tile_y, double height_scale, const fmt::AssetResolver &res);
void cleanup_water_shader();
} // namespace mtgodot
+112
View File
@@ -0,0 +1,112 @@
// mtnet::SecureCipher round trip — exercises the full Metin2 KX handshake +
// stream cipher + AEAD session-token path offline (no server needed).
#include "../src/net/secure_cipher.h"
#include "../src/net/wire.h"
#include <cstdio>
#include <cstring>
#include <string>
#include <vector>
using mtnet::SecureCipher;
static int g_fail = 0;
#define CHECK(c, msg) \
do { \
if (!(c)) { \
std::fprintf(stderr, "FAIL: %s\n", msg); \
++g_fail; \
} \
} while (0)
int main() {
CHECK(SecureCipher::ensure_sodium_init(), "sodium_init");
// --- key exchange: server sends KEY_CHALLENGE, client answers KEY_RESPONSE ---
SecureCipher server, client;
CHECK(server.initialize(), "server initialize");
CHECK(client.initialize(), "client initialize");
uint8_t server_pk[SecureCipher::PK_SIZE];
server.get_public_key(server_pk);
uint8_t challenge[SecureCipher::CHALLENGE_SIZE];
randombytes_buf(challenge, sizeof(challenge));
CHECK(client.compute_client_keys(server_pk), "client compute keys");
uint8_t client_pk[SecureCipher::PK_SIZE];
client.get_public_key(client_pk);
CHECK(server.compute_server_keys(client_pk), "server compute keys");
uint8_t response[crypto_auth_BYTES];
client.compute_challenge_response(challenge, response);
CHECK(server.verify_challenge_response(challenge, response), "server verifies challenge response");
// tamper -> must fail
uint8_t bad = response[0] ^ 0xFF;
uint8_t bad_resp[crypto_auth_BYTES];
std::memcpy(bad_resp, response, sizeof(response));
bad_resp[0] = bad;
CHECK(!server.verify_challenge_response(challenge, bad_resp), "tampered response rejected");
// --- KEY_COMPLETE: server encrypts a session token, client decrypts it ---
uint8_t token[SecureCipher::SESSION_TOKEN_SIZE];
randombytes_buf(token, sizeof(token));
uint8_t enc[SecureCipher::SESSION_TOKEN_SIZE + SecureCipher::TAG_SIZE];
uint8_t nonce[SecureCipher::NONCE_SIZE];
CHECK(server.encrypt_token(token, sizeof(token), enc, nonce), "server encrypt token");
uint8_t dec[SecureCipher::SESSION_TOKEN_SIZE];
CHECK(client.decrypt_token(enc, sizeof(enc), nonce, dec), "client decrypt token");
CHECK(std::memcmp(token, dec, sizeof(token)) == 0, "session token round trips");
server.set_activated(true);
client.set_activated(true);
// --- stream cipher: C->S traffic in arbitrary chunks, order-sensitive ---
const std::vector<size_t> chunk_sizes = {1, 4, 60, 3, 5, 100, 7, 64, 200, 13};
std::string acc_plain, acc_recovered;
for (size_t n : chunk_sizes) {
std::vector<uint8_t> buf(n);
for (size_t i = 0; i < n; ++i) {
buf[i] = static_cast<uint8_t>((acc_plain.size() + i) * 7 + 1);
}
acc_plain.append(reinterpret_cast<char *>(buf.data()), n);
client.encrypt_in_place(buf.data(), n); // C->S encrypt
// on the wire it looks like ciphertext; server decrypts the same bytes
server.decrypt_in_place(buf.data(), n);
acc_recovered.append(reinterpret_cast<char *>(buf.data()), n);
}
CHECK(acc_plain == acc_recovered, "C->S stream recovers across chunk boundaries");
CHECK(client.tx_nonce() == acc_plain.size(), "client tx byte counter advanced");
CHECK(server.rx_nonce() == acc_plain.size(), "server rx byte counter advanced");
// --- S->C direction is independent ---
{
std::vector<uint8_t> buf(150);
for (size_t i = 0; i < buf.size(); ++i) {
buf[i] = static_cast<uint8_t>(i ^ 0x5A);
}
std::vector<uint8_t> orig = buf;
server.encrypt_in_place(buf.data(), buf.size()); // S->C
CHECK(buf != orig, "S->C ciphertext differs from plaintext");
client.decrypt_in_place(buf.data(), buf.size());
CHECK(buf == orig, "S->C stream round trips");
}
// --- wire.h struct sizes (must match the fork's #pragma pack(1) layout) ---
CHECK(sizeof(mtnet::DynHeader) == 4, "DynHeader is 4 bytes");
CHECK(sizeof(mtnet::GCKeyChallenge) == 4 + 32 + 32 + 4, "GCKeyChallenge 72 bytes");
CHECK(sizeof(mtnet::CGKeyResponse) == 4 + 32 + 32, "CGKeyResponse 68 bytes");
CHECK(sizeof(mtnet::GCKeyComplete) == 4 + 48 + 24, "GCKeyComplete 76 bytes");
CHECK(sizeof(mtnet::CGLogin3) == 4 + 31 + 17, "CGLogin3 52 bytes");
CHECK(sizeof(mtnet::GCAuthSuccess) == 4 + 4 + 1, "GCAuthSuccess 9 bytes");
if (g_fail) {
std::fprintf(stderr, "%d check(s) failed\n", g_fail);
return 1;
}
std::printf("all checks passed\n");
return 0;
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+140
View File
@@ -0,0 +1,140 @@
// MarkImageSet + the GC_MARK_* body parsers — synthetic packets in, guild-mark
// pixels out. No socket: this covers the parse/decompress/blit path that the
// MarkClient (mark_client.h) drives over the wire.
#include "../src/net/mark_image.h"
#include "../src/net/wire.h"
#include <lzo/lzo1x.h>
#include <cstdio>
#include <cstring>
#include <vector>
using namespace mtnet;
static int g_fail = 0;
#define CHECK(c, msg) \
do { \
if (!(c)) { \
std::fprintf(stderr, "FAIL: %s\n", msg); \
++g_fail; \
} \
} while (0)
static void put_u16(std::vector<uint8_t> &b, uint16_t v) {
b.push_back((uint8_t)(v & 0xFF));
b.push_back((uint8_t)(v >> 8));
}
static void put_u32(std::vector<uint8_t> &b, uint32_t v) {
for (int i = 0; i < 4; ++i) {
b.push_back((uint8_t)((v >> (8 * i)) & 0xFF));
}
}
// LZO1X-compress one 64x48 RGBA block (MARK_BLOCK_PIXELS words).
static std::vector<uint8_t> compress_block(const uint32_t *px) {
lzo_init();
static std::vector<uint8_t> wrk(LZO1X_1_MEM_COMPRESS);
std::vector<uint8_t> out(MARK_BLOCK_PIXELS * 4 + MARK_BLOCK_PIXELS + 64);
lzo_uint out_len = out.size();
int r = lzo1x_1_compress((const uint8_t *)px, MARK_BLOCK_PIXELS * 4, out.data(), &out_len,
wrk.data());
if (r != LZO_E_OK) {
std::fprintf(stderr, "FAIL: lzo1x_1_compress -> %d\n", r);
++g_fail;
}
out.resize(out_len);
return out;
}
int main() {
// Guild 4242's mark lives at mark_id 1281 -> image 1, position 1 (row 0,
// col 1): pixel origin (16, 0). Guild 7 -> mark_id 3 -> image 0, pos 3.
const uint32_t kGuildA = 4242, kMarkA = MARK_PER_IMAGE + 1; // 1281
const uint32_t kGuildB = 7, kMarkB = 3;
// --- GC_MARK_IDXLIST body: count x {u16 guild_id, u16 mark_id} ---
MarkImageSet set;
{
std::vector<uint8_t> body;
put_u16(body, (uint16_t)kGuildA);
put_u16(body, (uint16_t)kMarkA);
put_u16(body, (uint16_t)kGuildB);
put_u16(body, (uint16_t)kMarkB);
size_t n = parse_mark_idxlist(body.data(), body.size(), 2, set);
CHECK(n == 2, "idxlist: 2 entries parsed");
}
CHECK(set.has_mark(kGuildA) && set.has_mark(kGuildB), "idxlist: both guilds registered");
CHECK(set.mark_id(kGuildA) == kMarkA, "idxlist: mark id stored");
CHECK(!set.has_mark(999), "idxlist: unknown guild absent");
// needed images = {0, 1} ascending
std::vector<int> need = set.needed_images();
CHECK(need.size() == 2 && need[0] == 0 && need[1] == 1, "needed_images = {0,1}");
// rect: guild A at image 1, (16, 0); guild B at image 0, (48, 0)
MarkRect ra = set.rect_of(kGuildA);
CHECK(ra.found && ra.img_idx == 1 && ra.x == 16 && ra.y == 0 && ra.w == 16 && ra.h == 12,
"rect_of(A) = img1 (16,0) 16x12");
MarkRect rb = set.rect_of(kGuildB);
CHECK(rb.found && rb.img_idx == 0 && rb.x == 48 && rb.y == 0, "rect_of(B) = img0 (48,0)");
// --- GC_MARK_BLOCK body for image 1, block 0 (covers marks at cols 0..3) ---
// Paint guild A's 16x12 cell (origin 16,0 within the block) a solid colour.
const uint32_t kColour = 0xFF3399CCu; // AABBGGRR
{
std::vector<uint32_t> block(MARK_BLOCK_PIXELS, 0);
for (int j = 0; j < GUILD_MARK_HEIGHT; ++j) {
for (int i = 0; i < GUILD_MARK_WIDTH; ++i) {
block[(size_t)j * MARK_BLOCK_WIDTH + (16 + i)] = kColour;
}
}
std::vector<uint8_t> comp = compress_block(block.data());
std::vector<uint8_t> body;
body.push_back(0); // block_pos 0
put_u32(body, (uint32_t)comp.size());
body.insert(body.end(), comp.begin(), comp.end());
size_t applied = parse_mark_block(body.data(), body.size(), /*img_idx=*/1, /*count=*/1, set);
CHECK(applied == 1, "block: 1 block applied");
}
// guild A's mark pixels should now be the solid colour; guild B (no block
// for image 0 yet) should come back empty.
std::vector<uint32_t> pa = set.mark_pixels(kGuildA);
CHECK(pa.size() == (size_t)(GUILD_MARK_WIDTH * GUILD_MARK_HEIGHT), "mark_pixels(A): 192 words");
bool all_colour = !pa.empty();
for (uint32_t p : pa) {
all_colour = all_colour && (p == kColour);
}
CHECK(all_colour, "mark_pixels(A): every pixel is the painted colour");
CHECK(set.mark_pixels(kGuildB).empty(), "mark_pixels(B): empty (image 0 not downloaded)");
// corrupt compressed data -> apply_block fails, image untouched
{
uint8_t junk[8] = {1, 2, 3, 4, 5, 6, 7, 8};
CHECK(!set.apply_block(1, 1, junk, sizeof(junk)), "apply_block: junk rejected");
}
// out-of-range indices
CHECK(!set.apply_block(-1, 0, (const uint8_t *)"x", 1), "apply_block: bad img idx");
CHECK(!set.apply_block(0, MARK_BLOCK_TOTAL_COUNT, (const uint8_t *)"x", 1),
"apply_block: bad block pos");
// truncated block body -> nothing applied, no crash
{
std::vector<uint8_t> body;
body.push_back(2);
put_u32(body, 9999); // claims 9999 bytes that aren't there
size_t applied = parse_mark_block(body.data(), body.size(), 0, 1, set);
CHECK(applied == 0, "block: truncated body -> 0 applied");
}
if (g_fail) {
std::fprintf(stderr, "%d check(s) failed\n", g_fail);
return 1;
}
std::printf("PASS: net_mark_test\n");
return 0;
}
+143
View File
@@ -0,0 +1,143 @@
// EterPack writer -> reader round trip (no external pack file needed).
#include "../src/pack/eterpack.h"
#include "../src/pack/pack_mount.h"
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <string>
#include <vector>
using namespace mtpack;
static int g_fail = 0;
#define CHECK(c, msg) \
do { \
if (!(c)) { \
std::fprintf(stderr, "FAIL: %s\n", msg); \
++g_fail; \
} \
} while (0)
static std::vector<uint8_t> bytes(const std::string &s) {
return std::vector<uint8_t>(s.begin(), s.end());
}
static bool roundtrip(bool encrypt, const char *tag) {
std::vector<InputFile> in;
in.push_back({"d:/ymir work/pc/warrior/warrior.gr2", bytes(std::string(5000, 'A'))}); // compressible
{
std::vector<uint8_t> rnd(4096);
for (size_t i = 0; i < rnd.size(); ++i) {
rnd[i] = static_cast<uint8_t>((i * 2654435761u) >> 13);
}
in.push_back({"textureset/metin2_a1.txt", rnd});
}
in.push_back({"tiny.dat", bytes("x")});
in.push_back({"nested/deep/path/file.bin", bytes("hello \x00 world binary\xff\xfe")});
std::string path = std::string(std::getenv("TMPDIR") ? std::getenv("TMPDIR") : "/tmp") +
"/mtpack_test_" + tag + ".epk";
std::string err;
if (!write_pack(path, in, encrypt, &err)) {
std::fprintf(stderr, "write_pack(%s): %s\n", tag, err.c_str());
return false;
}
EterPack pk;
if (!pk.open(path, &err)) {
std::fprintf(stderr, "open(%s): %s\n", tag, err.c_str());
return false;
}
CHECK(pk.count() == in.size(), "entry count");
CHECK(pk.name_field() == PACK_NAME_FIELD_DEFAULT, "derived name field == default");
for (const auto &f : in) {
CHECK(pk.has(f.name), (std::string("has ") + f.name).c_str());
// case / slash insensitivity
std::string up = f.name;
for (auto &c : up) {
c = static_cast<char>(std::toupper((unsigned char)c));
}
std::replace(up.begin(), up.end(), '/', '\\');
CHECK(pk.has(up), (std::string("has (norm) ") + f.name).c_str());
std::vector<uint8_t> got;
if (!pk.read(f.name, got, &err)) {
std::fprintf(stderr, "read(%s): %s\n", f.name.c_str(), err.c_str());
++g_fail;
continue;
}
CHECK(got == f.data, (std::string("bytes match ") + f.name).c_str());
}
CHECK(!pk.read("does/not/exist", *(new std::vector<uint8_t>()), &err), "missing file -> false");
std::remove(path.c_str());
return true;
}
static std::string tmp(const char *n) {
return std::string(std::getenv("TMPDIR") ? std::getenv("TMPDIR") : "/tmp") + "/mtpack_" + n;
}
static bool mount_test() {
// pack A: base assets, "ymir work/" layout
std::vector<InputFile> a;
a.push_back({"ymir work/ui/pattern/board_base.tga", bytes("BASE-BOARD-v1")});
a.push_back({"ymir work/tree/b1_pagoda.spt", bytes(std::string(2000, 'S'))});
a.push_back({"locale/loading.png", bytes("PNGDATA")});
std::string pa = tmp("mount_a.epk");
std::string err;
if (!write_pack(pa, a, false, &err)) {
std::fprintf(stderr, "write A: %s\n", err.c_str());
return false;
}
// pack B: a patch that overrides board_base
std::vector<InputFile> b;
b.push_back({"ymir work/ui/pattern/board_base.tga", bytes("BASE-BOARD-v2-PATCHED")});
std::string pb = tmp("metin2_patch_x.epk");
if (!write_pack(pb, b, true, &err)) {
std::fprintf(stderr, "write B: %s\n", err.c_str());
return false;
}
mtpack::PackMount m;
CHECK(m.mount(pa, &err), ("mount A: " + err).c_str());
CHECK(m.mount(pb, &err), ("mount B: " + err).c_str());
CHECK(m.pack_count() == 2, "2 packs mounted");
std::vector<uint8_t> out;
// full path
CHECK(m.has("ymir work/tree/b1_pagoda.spt"), "has by full path");
CHECK(m.read("ymir work/tree/b1_pagoda.spt", out) && out.size() == 2000, "read by full path");
// virtual path with drive + backslashes + case
CHECK(m.has("D:\\YMIR WORK\\Tree\\B1_Pagoda.spt"), "has by d:\\ virtual path");
CHECK(m.read("d:/ymir work/tree/b1_pagoda.spt", out) && out.size() == 2000, "read by virtual");
// "ymir work/"-suffix keying: a vpath that omits the leading dirs
CHECK(m.read("somewhere/ymir work/ui/pattern/board_base.tga", out), "read via ymir-suffix");
// later pack (patch) wins
CHECK(std::string(out.begin(), out.end()) == "BASE-BOARD-v2-PATCHED", "patch pack overrides base");
// non-ymir path still works
CHECK(m.read("locale/loading.png", out) &&
std::string(out.begin(), out.end()) == "PNGDATA",
"non-ymir path");
CHECK(!m.has("does/not/exist"), "missing -> false");
std::remove(pa.c_str());
std::remove(pb.c_str());
return true;
}
int main() {
CHECK(roundtrip(false, "plain"), "plain roundtrip ran");
CHECK(roundtrip(true, "enc"), "encrypted roundtrip ran");
CHECK(mount_test(), "PackMount test ran");
if (g_fail) {
std::fprintf(stderr, "%d check(s) failed\n", g_fail);
return 1;
}
std::printf("all checks passed\n");
return 0;
}
+150
View File
@@ -0,0 +1,150 @@
// item_proto / mob_proto reader — against the real locale files if present.
// Set M2_ASSETS to the Metin2 assets dir, else falls back to a repo-relative
// guess; skips (passes) if the files are not found.
#include "../src/proto/proto.h"
#include <cstdio>
#include <cstdlib>
#include <string>
using namespace mtproto;
static int g_fail = 0;
#define CHECK(c, msg) \
do { \
if (!(c)) { \
std::fprintf(stderr, "FAIL: %s\n", msg); \
++g_fail; \
} \
} while (0)
static std::string assets_root() {
if (const char *e = std::getenv("M2_ASSETS")) {
return e;
}
return "../../assets"; // ctest cwd = build/extension -> <repo>/assets
}
static bool exists(const std::string &p) {
FILE *f = std::fopen(p.c_str(), "rb");
if (f) {
std::fclose(f);
return true;
}
return false;
}
int main() {
const std::string base = assets_root() + "/locale/locale/en";
const std::string ipath = base + "/item_proto";
const std::string mpath = base + "/mob_proto";
if (!exists(ipath) || !exists(mpath)) {
std::printf("skipped (no locale proto files at %s)\n", base.c_str());
return 0;
}
// --- item_proto ---
{
Proto p;
std::string err;
bool ok = load_proto(ipath, ITEM_PROTO_KEY, p, &err);
CHECK(ok, ("load item_proto: " + err).c_str());
if (ok) {
CHECK(p.fourcc == 0x5850494Du, "item fourcc MIPX");
CHECK(p.version == 1, "item version 1");
CHECK(p.stride == 236, "item stride 236 (== pack(1) sizeof TItemTable)");
CHECK(p.elements > 100, "item elements > 100");
CHECK(p.blob.size() == static_cast<size_t>(p.stride) * p.elements, "item blob size");
// every record: vnum monotonically increasing, name printable ascii
uint32_t prev = 0;
int named = 0, mono = 1;
uint8_t max_spec = 0;
for (uint32_t i = 0; i < p.elements; ++i) {
ItemRecord it = parse_item(p.record(i), p.stride);
if (it.vnum && it.vnum <= prev) {
mono = 0;
}
prev = it.vnum ? it.vnum : prev;
if (!it.name.empty()) {
++named;
}
if (it.specular > max_spec) {
max_spec = it.specular;
}
}
CHECK(mono, "item vnums non-decreasing");
CHECK(named > p.elements * 0.8, "item: >80% have a name");
std::printf("item_proto: %u items, stride %u, %d named, max bSpecular=%u\n",
p.elements, p.stride, named, max_spec);
ItemRecord first = parse_item(p.record(0), p.stride);
std::printf(" [0] vnum=%u '%s' type=%u sub=%u weight=%u\n", first.vnum,
first.name.c_str(), first.type, first.sub_type, first.weight);
CHECK(first.vnum > 0 && first.vnum < 100000, "item[0] vnum sane");
CHECK(!first.name.empty(), "item[0] has a name");
// alValues[3] = body-armor shape index (anchored by bSpecular @234).
// "Monk Plate Armour" 11200..11209 all share shape 3; specular ramps
// with the refine level (0 -> 100).
bool found_armor = false;
for (uint32_t i = 0; i < p.elements; ++i) {
ItemRecord it = parse_item(p.record(i), p.stride);
if (it.vnum == 11209) {
found_armor = true;
std::printf(" 11209 values=[%d,%d,%d,%d,%d,%d] spec=%u\n", it.values[0],
it.values[1], it.values[2], it.values[3], it.values[4], it.values[5],
it.specular);
CHECK(it.values[3] == 3, "item 11209: values[3] == shape 3");
CHECK(it.specular == 100, "item 11209 (+9): bSpecular == 100");
}
if (it.vnum == 11200) {
CHECK(it.values[3] == 3 && it.specular == 0,
"item 11200 (+0): shape 3, specular 0");
}
}
CHECK(found_armor, "item_proto contains vnum 11209");
}
}
// --- mob_proto ---
{
Proto p;
std::string err;
bool ok = load_proto(mpath, MOB_PROTO_KEY, p, &err);
CHECK(ok, ("load mob_proto: " + err).c_str());
if (ok) {
CHECK(p.fourcc == 0x54504D4Du, "mob fourcc MMPT");
CHECK(p.stride == 335, "mob stride 335 (derived from realSize/elements)");
CHECK(p.elements > 100, "mob elements > 100");
int named = 0, maxlvl = 0;
for (uint32_t i = 0; i < p.elements; ++i) {
MobRecord m = parse_mob(p.record(i), p.stride);
if (!m.name.empty()) {
++named;
}
if (m.level > maxlvl) {
maxlvl = m.level;
}
}
CHECK(named > p.elements * 0.8, "mob: >80% named");
CHECK(maxlvl > 20 && maxlvl < 256, "mob levels in a sane range");
MobRecord first = parse_mob(p.record(0), p.stride);
std::printf("mob_proto: %u mobs, stride %u, %d named, maxlvl=%d\n [0] vnum=%u '%s' "
"type=%u rank=%u level=%u\n",
p.elements, p.stride, named, maxlvl, first.vnum, first.name.c_str(), first.type,
first.rank, first.level);
CHECK(first.vnum > 0 && first.vnum < 60000, "mob[0] vnum sane");
CHECK(!first.name.empty(), "mob[0] has a name");
}
}
if (g_fail) {
std::fprintf(stderr, "%d check(s) failed\n", g_fail);
return 1;
}
std::printf("all checks passed\n");
return 0;
}
+43
View File
@@ -0,0 +1,43 @@
# extension/third_party vendored native dependencies for the GDExtension.
#
# Phase 1 (macOS) used Homebrew for libsodium / libzstd / liblzo2. Those don't
# exist on the Android NDK or iOS SDK sysroots, so the mobile bring-up (BACKLOG
# F1/F2) needs them built from source as part of our own build. All three are
# pinned here and produce static libs that link into libmtgodot:
#
# sodium <- libsodium-cmake submodule (wraps jedisct1/libsodium)
# libzstd_static <- facebook/zstd submodule, its own build/cmake project
# minilzo <- vendored miniLZO source (LZO1X, ~4 files) see NOTICE
#
# See docs/THIRD-PARTY.md for versions / licenses. liblzo2 (and thus miniLZO) is
# GPL: fine for this internal, non-published project same footing as
# libgr2/src/oodle1.c but must be swapped or re-licensed before any release.
# Everything here is archived into the SHARED libmtgodot, so it must be PIC.
set(CMAKE_POSITION_INDEPENDENT_CODE ON)
# --- libsodium ------------------------------------------------------------------
set(SODIUM_DISABLE_TESTS ON CACHE BOOL "" FORCE)
set(SODIUM_MINIMAL OFF CACHE BOOL "" FORCE)
add_subdirectory(libsodium-cmake EXCLUDE_FROM_ALL)
# The wrapper's target is `sodium`; give it a namespaced alias for consumers.
add_library(mt3p::sodium ALIAS sodium)
# --- libzstd ------------------------------------------------------------------
set(ZSTD_BUILD_PROGRAMS OFF CACHE BOOL "" FORCE)
set(ZSTD_BUILD_SHARED OFF CACHE BOOL "" FORCE)
set(ZSTD_BUILD_STATIC ON CACHE BOOL "" FORCE)
set(ZSTD_BUILD_TESTS OFF CACHE BOOL "" FORCE)
set(ZSTD_BUILD_CONTRIB OFF CACHE BOOL "" FORCE)
set(ZSTD_LEGACY_SUPPORT OFF CACHE BOOL "" FORCE)
set(ZSTD_MULTITHREAD_SUPPORT OFF CACHE BOOL "" FORCE)
add_subdirectory(zstd/build/cmake zstd-build EXCLUDE_FROM_ALL)
add_library(mt3p::zstd ALIAS libzstd_static)
# --- miniLZO ------------------------------------------------------------------
# One translation unit, an amalgamation generated from the LZO sources. Its
# public API (<lzo/lzo1x.h> via the shim header) is a strict subset of full LZO.
add_library(minilzo STATIC minilzo/minilzo.c)
target_include_directories(minilzo PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/minilzo")
set_target_properties(minilzo PROPERTIES C_STANDARD 99)
add_library(mt3p::minilzo ALIAS minilzo)
+3
View File
@@ -0,0 +1,3 @@
Authors of the LZO data compression library:
Markus F.X.J. Oberhumer. Invented, designed and implemented LZO.
+339
View File
@@ -0,0 +1,339 @@
GNU GENERAL PUBLIC LICENSE
Version 2, June 1991
Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The licenses for most software are designed to take away your
freedom to share and change it. By contrast, the GNU General Public
License is intended to guarantee your freedom to share and change free
software--to make sure the software is free for all its users. This
General Public License applies to most of the Free Software
Foundation's software and to any other program whose authors commit to
using it. (Some other Free Software Foundation software is covered by
the GNU Lesser General Public License instead.) You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
this service if you wish), that you receive source code or can get it
if you want it, that you can change the software or use pieces of it
in new free programs; and that you know you can do these things.
To protect your rights, we need to make restrictions that forbid
anyone to deny you these rights or to ask you to surrender the rights.
These restrictions translate to certain responsibilities for you if you
distribute copies of the software, or if you modify it.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must give the recipients all the rights that
you have. You must make sure that they, too, receive or can get the
source code. And you must show them these terms so they know their
rights.
We protect your rights with two steps: (1) copyright the software, and
(2) offer you this license which gives you legal permission to copy,
distribute and/or modify the software.
Also, for each author's protection and ours, we want to make certain
that everyone understands that there is no warranty for this free
software. If the software is modified by someone else and passed on, we
want its recipients to know that what they have is not the original, so
that any problems introduced by others will not reflect on the original
authors' reputations.
Finally, any free program is threatened constantly by software
patents. We wish to avoid the danger that redistributors of a free
program will individually obtain patent licenses, in effect making the
program proprietary. To prevent this, we have made it clear that any
patent must be licensed for everyone's free use or not licensed at all.
The precise terms and conditions for copying, distribution and
modification follow.
GNU GENERAL PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. This License applies to any program or other work which contains
a notice placed by the copyright holder saying it may be distributed
under the terms of this General Public License. The "Program", below,
refers to any such program or work, and a "work based on the Program"
means either the Program or any derivative work under copyright law:
that is to say, a work containing the Program or a portion of it,
either verbatim or with modifications and/or translated into another
language. (Hereinafter, translation is included without limitation in
the term "modification".) Each licensee is addressed as "you".
Activities other than copying, distribution and modification are not
covered by this License; they are outside its scope. The act of
running the Program is not restricted, and the output from the Program
is covered only if its contents constitute a work based on the
Program (independent of having been made by running the Program).
Whether that is true depends on what the Program does.
1. You may copy and distribute verbatim copies of the Program's
source code as you receive it, in any medium, provided that you
conspicuously and appropriately publish on each copy an appropriate
copyright notice and disclaimer of warranty; keep intact all the
notices that refer to this License and to the absence of any warranty;
and give any other recipients of the Program a copy of this License
along with the Program.
You may charge a fee for the physical act of transferring a copy, and
you may at your option offer warranty protection in exchange for a fee.
2. You may modify your copy or copies of the Program or any portion
of it, thus forming a work based on the Program, and copy and
distribute such modifications or work under the terms of Section 1
above, provided that you also meet all of these conditions:
a) You must cause the modified files to carry prominent notices
stating that you changed the files and the date of any change.
b) You must cause any work that you distribute or publish, that in
whole or in part contains or is derived from the Program or any
part thereof, to be licensed as a whole at no charge to all third
parties under the terms of this License.
c) If the modified program normally reads commands interactively
when run, you must cause it, when started running for such
interactive use in the most ordinary way, to print or display an
announcement including an appropriate copyright notice and a
notice that there is no warranty (or else, saying that you provide
a warranty) and that users may redistribute the program under
these conditions, and telling the user how to view a copy of this
License. (Exception: if the Program itself is interactive but
does not normally print such an announcement, your work based on
the Program is not required to print an announcement.)
These requirements apply to the modified work as a whole. If
identifiable sections of that work are not derived from the Program,
and can be reasonably considered independent and separate works in
themselves, then this License, and its terms, do not apply to those
sections when you distribute them as separate works. But when you
distribute the same sections as part of a whole which is a work based
on the Program, the distribution of the whole must be on the terms of
this License, whose permissions for other licensees extend to the
entire whole, and thus to each and every part regardless of who wrote it.
Thus, it is not the intent of this section to claim rights or contest
your rights to work written entirely by you; rather, the intent is to
exercise the right to control the distribution of derivative or
collective works based on the Program.
In addition, mere aggregation of another work not based on the Program
with the Program (or with a work based on the Program) on a volume of
a storage or distribution medium does not bring the other work under
the scope of this License.
3. You may copy and distribute the Program (or a work based on it,
under Section 2) in object code or executable form under the terms of
Sections 1 and 2 above provided that you also do one of the following:
a) Accompany it with the complete corresponding machine-readable
source code, which must be distributed under the terms of Sections
1 and 2 above on a medium customarily used for software interchange; or,
b) Accompany it with a written offer, valid for at least three
years, to give any third party, for a charge no more than your
cost of physically performing source distribution, a complete
machine-readable copy of the corresponding source code, to be
distributed under the terms of Sections 1 and 2 above on a medium
customarily used for software interchange; or,
c) Accompany it with the information you received as to the offer
to distribute corresponding source code. (This alternative is
allowed only for noncommercial distribution and only if you
received the program in object code or executable form with such
an offer, in accord with Subsection b above.)
The source code for a work means the preferred form of the work for
making modifications to it. For an executable work, complete source
code means all the source code for all modules it contains, plus any
associated interface definition files, plus the scripts used to
control compilation and installation of the executable. However, as a
special exception, the source code distributed need not include
anything that is normally distributed (in either source or binary
form) with the major components (compiler, kernel, and so on) of the
operating system on which the executable runs, unless that component
itself accompanies the executable.
If distribution of executable or object code is made by offering
access to copy from a designated place, then offering equivalent
access to copy the source code from the same place counts as
distribution of the source code, even though third parties are not
compelled to copy the source along with the object code.
4. You may not copy, modify, sublicense, or distribute the Program
except as expressly provided under this License. Any attempt
otherwise to copy, modify, sublicense or distribute the Program is
void, and will automatically terminate your rights under this License.
However, parties who have received copies, or rights, from you under
this License will not have their licenses terminated so long as such
parties remain in full compliance.
5. You are not required to accept this License, since you have not
signed it. However, nothing else grants you permission to modify or
distribute the Program or its derivative works. These actions are
prohibited by law if you do not accept this License. Therefore, by
modifying or distributing the Program (or any work based on the
Program), you indicate your acceptance of this License to do so, and
all its terms and conditions for copying, distributing or modifying
the Program or works based on it.
6. Each time you redistribute the Program (or any work based on the
Program), the recipient automatically receives a license from the
original licensor to copy, distribute or modify the Program subject to
these terms and conditions. You may not impose any further
restrictions on the recipients' exercise of the rights granted herein.
You are not responsible for enforcing compliance by third parties to
this License.
7. If, as a consequence of a court judgment or allegation of patent
infringement or for any other reason (not limited to patent issues),
conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot
distribute so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you
may not distribute the Program at all. For example, if a patent
license would not permit royalty-free redistribution of the Program by
all those who receive copies directly or indirectly through you, then
the only way you could satisfy both it and this License would be to
refrain entirely from distribution of the Program.
If any portion of this section is held invalid or unenforceable under
any particular circumstance, the balance of the section is intended to
apply and the section as a whole is intended to apply in other
circumstances.
It is not the purpose of this section to induce you to infringe any
patents or other property right claims or to contest validity of any
such claims; this section has the sole purpose of protecting the
integrity of the free software distribution system, which is
implemented by public license practices. Many people have made
generous contributions to the wide range of software distributed
through that system in reliance on consistent application of that
system; it is up to the author/donor to decide if he or she is willing
to distribute software through any other system and a licensee cannot
impose that choice.
This section is intended to make thoroughly clear what is believed to
be a consequence of the rest of this License.
8. If the distribution and/or use of the Program is restricted in
certain countries either by patents or by copyrighted interfaces, the
original copyright holder who places the Program under this License
may add an explicit geographical distribution limitation excluding
those countries, so that distribution is permitted only in or among
countries not thus excluded. In such case, this License incorporates
the limitation as if written in the body of this License.
9. The Free Software Foundation may publish revised and/or new versions
of the General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the Program
specifies a version number of this License which applies to it and "any
later version", you have the option of following the terms and conditions
either of that version or of any later version published by the Free
Software Foundation. If the Program does not specify a version number of
this License, you may choose any version ever published by the Free Software
Foundation.
10. If you wish to incorporate parts of the Program into other free
programs whose distribution conditions are different, write to the author
to ask for permission. For software which is copyrighted by the Free
Software Foundation, write to the Free Software Foundation; we sometimes
make exceptions for this. Our decision will be guided by the two goals
of preserving the free status of all derivatives of our free software and
of promoting the sharing and reuse of software generally.
NO WARRANTY
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
REPAIR OR CORRECTION.
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
POSSIBILITY OF SUCH DAMAGES.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
convey the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License along
with this program; if not, write to the Free Software Foundation, Inc.,
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
Also add information on how to contact you by electronic and paper mail.
If the program is interactive, make it output a short notice like this
when it starts in an interactive mode:
Gnomovision version 69, Copyright (C) year name of author
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, the commands you use may
be called something other than `show w' and `show c'; they could even be
mouse-clicks or menu items--whatever suits your program.
You should also get your employer (if you work as a programmer) or your
school, if any, to sign a "copyright disclaimer" for the program, if
necessary. Here is a sample; alter the names:
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
`Gnomovision' (which makes passes at compilers) written by James Hacker.
<signature of Ty Coon>, 1 April 1989
Ty Coon, President of Vice
This General Public License does not permit incorporating your program into
proprietary programs. If your program is a subroutine library, you may
consider it more useful to permit linking proprietary applications with the
library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License.
+123
View File
@@ -0,0 +1,123 @@
============================================================================
miniLZO -- mini subset of the LZO real-time data compression library
============================================================================
Author : Markus Franz Xaver Johannes Oberhumer
<markus@oberhumer.com>
http://www.oberhumer.com/opensource/lzo/
Version : 2.10
Date : 01 Mar 2017
I've created miniLZO for projects where it is inconvenient to
include (or require) the full LZO source code just because you
want to add a little bit of data compression to your application.
miniLZO implements the LZO1X-1 compressor and both the standard and
safe LZO1X decompressor. Apart from fast compression it also useful
for situations where you want to use pre-compressed data files (which
must have been compressed with LZO1X-999).
miniLZO consists of one C source file and three header files:
minilzo.c
minilzo.h, lzoconf.h, lzodefs.h
To use miniLZO just copy these files into your source directory, add
minilzo.c to your Makefile and #include minilzo.h from your program.
Note: you also must distribute this file ('README.LZO') with your project.
minilzo.o compiles to about 6 KiB (using gcc or Visual C on an i386), and
the sources are about 30 KiB when packed with zip - so there's no more
excuse that your application doesn't support data compression :-)
For more information, documentation, example programs and other support
files (like Makefiles and build scripts) please download the full LZO
package from
http://www.oberhumer.com/opensource/lzo/
Have fun,
Markus
P.S. minilzo.c is generated automatically from the LZO sources and
therefore functionality is completely identical
Appendix A: building miniLZO
----------------------------
miniLZO is written such a way that it should compile and run
out-of-the-box on most machines.
If you are running on a very unusual architecture and lzo_init() fails then
you should first recompile with '-DLZO_DEBUG' to see what causes the failure.
The most probable case is something like 'sizeof(void *) != sizeof(size_t)'.
After identifying the problem you can compile by adding some defines
like '-DSIZEOF_VOID_P=8' to your Makefile.
The best solution is (of course) using Autoconf - if your project uses
Autoconf anyway just add '-DMINILZO_HAVE_CONFIG_H' to your compiler
flags when compiling minilzo.c. See the LZO distribution for an example
how to set up configure.ac.
Appendix B: list of public functions available in miniLZO
---------------------------------------------------------
Library initialization
lzo_init()
Compression
lzo1x_1_compress()
Decompression
lzo1x_decompress()
lzo1x_decompress_safe()
Checksum functions
lzo_adler32()
Version functions
lzo_version()
lzo_version_string()
lzo_version_date()
Portable (but slow) string functions
lzo_memcmp()
lzo_memcpy()
lzo_memmove()
lzo_memset()
Appendix C: suggested macros for 'configure.ac' when using Autoconf
-------------------------------------------------------------------
Checks for typedefs and structures
AC_CHECK_TYPE(ptrdiff_t,long)
AC_TYPE_SIZE_T
AC_CHECK_SIZEOF(short)
AC_CHECK_SIZEOF(int)
AC_CHECK_SIZEOF(long)
AC_CHECK_SIZEOF(long long)
AC_CHECK_SIZEOF(__int64)
AC_CHECK_SIZEOF(void *)
AC_CHECK_SIZEOF(size_t)
AC_CHECK_SIZEOF(ptrdiff_t)
Checks for compiler characteristics
AC_C_CONST
Checks for library functions
AC_CHECK_FUNCS(memcmp memcpy memmove memset)
Appendix D: Copyright
---------------------
LZO and miniLZO are Copyright (C) 1996-2017 Markus Franz Xaver Oberhumer
All Rights Reserved.
LZO and miniLZO are distributed under the terms of the GNU General
Public License (GPL). See the file COPYING.
Special licenses for commercial and other applications which
are not willing to accept the GNU General Public License
are available by contacting the author.
+12
View File
@@ -0,0 +1,12 @@
/* Compatibility shim: the full LZO distribution exposes the LZO1X API as
* <lzo/lzo1x.h>. We vendor only miniLZO (LZO1X-1 compressor + safe/standard
* LZO1X decompressor), which is all mtproto's CLZO path needs
* (lzo_init / lzo1x_decompress_safe / LZO_E_OK / lzo_uint). This header lets
* extension/src/proto/proto.cpp keep its `#include <lzo/lzo1x.h>` unchanged.
*/
#ifndef MTGODOT_MINILZO_LZO1X_SHIM_H
#define MTGODOT_MINILZO_LZO1X_SHIM_H
#include "../minilzo.h"
#endif
+453
View File
@@ -0,0 +1,453 @@
/* lzoconf.h -- configuration of the LZO data compression library
This file is part of the LZO real-time data compression library.
Copyright (C) 1996-2017 Markus Franz Xaver Johannes Oberhumer
All Rights Reserved.
The LZO library is free software; you can redistribute it and/or
modify it under the terms of the GNU General Public License as
published by the Free Software Foundation; either version 2 of
the License, or (at your option) any later version.
The LZO library is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with the LZO library; see the file COPYING.
If not, write to the Free Software Foundation, Inc.,
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
Markus F.X.J. Oberhumer
<markus@oberhumer.com>
http://www.oberhumer.com/opensource/lzo/
*/
#ifndef __LZOCONF_H_INCLUDED
#define __LZOCONF_H_INCLUDED 1
#define LZO_VERSION 0x20a0 /* 2.10 */
#define LZO_VERSION_STRING "2.10"
#define LZO_VERSION_DATE "Mar 01 2017"
/* internal Autoconf configuration file - only used when building LZO */
#if defined(LZO_HAVE_CONFIG_H)
# include <config.h>
#endif
#include <limits.h>
#include <stddef.h>
/***********************************************************************
// LZO requires a conforming <limits.h>
************************************************************************/
#if !defined(CHAR_BIT) || (CHAR_BIT != 8)
# error "invalid CHAR_BIT"
#endif
#if !defined(UCHAR_MAX) || !defined(USHRT_MAX) || !defined(UINT_MAX) || !defined(ULONG_MAX)
# error "check your compiler installation"
#endif
#if (USHRT_MAX < 1) || (UINT_MAX < 1) || (ULONG_MAX < 1)
# error "your limits.h macros are broken"
#endif
/* get OS and architecture defines */
#ifndef __LZODEFS_H_INCLUDED
#include <lzo/lzodefs.h>
#endif
#ifdef __cplusplus
extern "C" {
#endif
/***********************************************************************
// some core defines
************************************************************************/
/* memory checkers */
#if !defined(__LZO_CHECKER)
# if defined(__BOUNDS_CHECKING_ON)
# define __LZO_CHECKER 1
# elif defined(__CHECKER__)
# define __LZO_CHECKER 1
# elif defined(__INSURE__)
# define __LZO_CHECKER 1
# elif defined(__PURIFY__)
# define __LZO_CHECKER 1
# endif
#endif
/***********************************************************************
// integral and pointer types
************************************************************************/
/* lzo_uint must match size_t */
#if !defined(LZO_UINT_MAX)
# if (LZO_ABI_LLP64)
# if (LZO_OS_WIN64)
typedef unsigned __int64 lzo_uint;
typedef __int64 lzo_int;
# define LZO_TYPEOF_LZO_INT LZO_TYPEOF___INT64
# else
typedef lzo_ullong_t lzo_uint;
typedef lzo_llong_t lzo_int;
# define LZO_TYPEOF_LZO_INT LZO_TYPEOF_LONG_LONG
# endif
# define LZO_SIZEOF_LZO_INT 8
# define LZO_UINT_MAX 0xffffffffffffffffull
# define LZO_INT_MAX 9223372036854775807LL
# define LZO_INT_MIN (-1LL - LZO_INT_MAX)
# elif (LZO_ABI_IP32L64) /* MIPS R5900 */
typedef unsigned int lzo_uint;
typedef int lzo_int;
# define LZO_SIZEOF_LZO_INT LZO_SIZEOF_INT
# define LZO_TYPEOF_LZO_INT LZO_TYPEOF_INT
# define LZO_UINT_MAX UINT_MAX
# define LZO_INT_MAX INT_MAX
# define LZO_INT_MIN INT_MIN
# elif (ULONG_MAX >= LZO_0xffffffffL)
typedef unsigned long lzo_uint;
typedef long lzo_int;
# define LZO_SIZEOF_LZO_INT LZO_SIZEOF_LONG
# define LZO_TYPEOF_LZO_INT LZO_TYPEOF_LONG
# define LZO_UINT_MAX ULONG_MAX
# define LZO_INT_MAX LONG_MAX
# define LZO_INT_MIN LONG_MIN
# else
# error "lzo_uint"
# endif
#endif
/* The larger type of lzo_uint and lzo_uint32_t. */
#if (LZO_SIZEOF_LZO_INT >= 4)
# define lzo_xint lzo_uint
#else
# define lzo_xint lzo_uint32_t
#endif
typedef int lzo_bool;
/* sanity checks */
LZO_COMPILE_TIME_ASSERT_HEADER(sizeof(lzo_int) == LZO_SIZEOF_LZO_INT)
LZO_COMPILE_TIME_ASSERT_HEADER(sizeof(lzo_uint) == LZO_SIZEOF_LZO_INT)
LZO_COMPILE_TIME_ASSERT_HEADER(sizeof(lzo_xint) >= sizeof(lzo_uint))
LZO_COMPILE_TIME_ASSERT_HEADER(sizeof(lzo_xint) >= sizeof(lzo_uint32_t))
#ifndef __LZO_MMODEL
#define __LZO_MMODEL /*empty*/
#endif
/* no typedef here because of const-pointer issues */
#define lzo_bytep unsigned char __LZO_MMODEL *
#define lzo_charp char __LZO_MMODEL *
#define lzo_voidp void __LZO_MMODEL *
#define lzo_shortp short __LZO_MMODEL *
#define lzo_ushortp unsigned short __LZO_MMODEL *
#define lzo_intp lzo_int __LZO_MMODEL *
#define lzo_uintp lzo_uint __LZO_MMODEL *
#define lzo_xintp lzo_xint __LZO_MMODEL *
#define lzo_voidpp lzo_voidp __LZO_MMODEL *
#define lzo_bytepp lzo_bytep __LZO_MMODEL *
#define lzo_int8_tp lzo_int8_t __LZO_MMODEL *
#define lzo_uint8_tp lzo_uint8_t __LZO_MMODEL *
#define lzo_int16_tp lzo_int16_t __LZO_MMODEL *
#define lzo_uint16_tp lzo_uint16_t __LZO_MMODEL *
#define lzo_int32_tp lzo_int32_t __LZO_MMODEL *
#define lzo_uint32_tp lzo_uint32_t __LZO_MMODEL *
#if defined(lzo_int64_t)
#define lzo_int64_tp lzo_int64_t __LZO_MMODEL *
#define lzo_uint64_tp lzo_uint64_t __LZO_MMODEL *
#endif
/* Older LZO versions used to support ancient systems and memory models
* such as 16-bit MSDOS with __huge pointers or Cray PVP, but these
* obsolete configurations are not supported any longer.
*/
#if defined(__LZO_MMODEL_HUGE)
#error "__LZO_MMODEL_HUGE memory model is unsupported"
#endif
#if (LZO_MM_PVP)
#error "LZO_MM_PVP memory model is unsupported"
#endif
#if (LZO_SIZEOF_INT < 4)
#error "LZO_SIZEOF_INT < 4 is unsupported"
#endif
#if (__LZO_UINTPTR_T_IS_POINTER)
#error "__LZO_UINTPTR_T_IS_POINTER is unsupported"
#endif
LZO_COMPILE_TIME_ASSERT_HEADER(sizeof(int) >= 4)
LZO_COMPILE_TIME_ASSERT_HEADER(sizeof(lzo_uint) >= 4)
/* Strange configurations where sizeof(lzo_uint) != sizeof(size_t) should
* work but have not received much testing lately, so be strict here.
*/
LZO_COMPILE_TIME_ASSERT_HEADER(sizeof(lzo_uint) == sizeof(size_t))
LZO_COMPILE_TIME_ASSERT_HEADER(sizeof(lzo_uint) == sizeof(ptrdiff_t))
LZO_COMPILE_TIME_ASSERT_HEADER(sizeof(lzo_uint) == sizeof(lzo_uintptr_t))
LZO_COMPILE_TIME_ASSERT_HEADER(sizeof(void *) == sizeof(lzo_uintptr_t))
LZO_COMPILE_TIME_ASSERT_HEADER(sizeof(char *) == sizeof(lzo_uintptr_t))
LZO_COMPILE_TIME_ASSERT_HEADER(sizeof(long *) == sizeof(lzo_uintptr_t))
LZO_COMPILE_TIME_ASSERT_HEADER(sizeof(void *) == sizeof(lzo_voidp))
LZO_COMPILE_TIME_ASSERT_HEADER(sizeof(char *) == sizeof(lzo_bytep))
/***********************************************************************
// function types
************************************************************************/
/* name mangling */
#if !defined(__LZO_EXTERN_C)
# ifdef __cplusplus
# define __LZO_EXTERN_C extern "C"
# else
# define __LZO_EXTERN_C extern
# endif
#endif
/* calling convention */
#if !defined(__LZO_CDECL)
# define __LZO_CDECL __lzo_cdecl
#endif
/* DLL export information */
#if !defined(__LZO_EXPORT1)
# define __LZO_EXPORT1 /*empty*/
#endif
#if !defined(__LZO_EXPORT2)
# define __LZO_EXPORT2 /*empty*/
#endif
/* __cdecl calling convention for public C and assembly functions */
#if !defined(LZO_PUBLIC)
# define LZO_PUBLIC(r) __LZO_EXPORT1 r __LZO_EXPORT2 __LZO_CDECL
#endif
#if !defined(LZO_EXTERN)
# define LZO_EXTERN(r) __LZO_EXTERN_C LZO_PUBLIC(r)
#endif
#if !defined(LZO_PRIVATE)
# define LZO_PRIVATE(r) static r __LZO_CDECL
#endif
/* function types */
typedef int
(__LZO_CDECL *lzo_compress_t) ( const lzo_bytep src, lzo_uint src_len,
lzo_bytep dst, lzo_uintp dst_len,
lzo_voidp wrkmem );
typedef int
(__LZO_CDECL *lzo_decompress_t) ( const lzo_bytep src, lzo_uint src_len,
lzo_bytep dst, lzo_uintp dst_len,
lzo_voidp wrkmem );
typedef int
(__LZO_CDECL *lzo_optimize_t) ( lzo_bytep src, lzo_uint src_len,
lzo_bytep dst, lzo_uintp dst_len,
lzo_voidp wrkmem );
typedef int
(__LZO_CDECL *lzo_compress_dict_t)(const lzo_bytep src, lzo_uint src_len,
lzo_bytep dst, lzo_uintp dst_len,
lzo_voidp wrkmem,
const lzo_bytep dict, lzo_uint dict_len );
typedef int
(__LZO_CDECL *lzo_decompress_dict_t)(const lzo_bytep src, lzo_uint src_len,
lzo_bytep dst, lzo_uintp dst_len,
lzo_voidp wrkmem,
const lzo_bytep dict, lzo_uint dict_len );
/* Callback interface. Currently only the progress indicator ("nprogress")
* is used, but this may change in a future release. */
struct lzo_callback_t;
typedef struct lzo_callback_t lzo_callback_t;
#define lzo_callback_p lzo_callback_t __LZO_MMODEL *
/* malloc & free function types */
typedef lzo_voidp (__LZO_CDECL *lzo_alloc_func_t)
(lzo_callback_p self, lzo_uint items, lzo_uint size);
typedef void (__LZO_CDECL *lzo_free_func_t)
(lzo_callback_p self, lzo_voidp ptr);
/* a progress indicator callback function */
typedef void (__LZO_CDECL *lzo_progress_func_t)
(lzo_callback_p, lzo_uint, lzo_uint, int);
struct lzo_callback_t
{
/* custom allocators (set to 0 to disable) */
lzo_alloc_func_t nalloc; /* [not used right now] */
lzo_free_func_t nfree; /* [not used right now] */
/* a progress indicator callback function (set to 0 to disable) */
lzo_progress_func_t nprogress;
/* INFO: the first parameter "self" of the nalloc/nfree/nprogress
* callbacks points back to this struct, so you are free to store
* some extra info in the following variables. */
lzo_voidp user1;
lzo_xint user2;
lzo_xint user3;
};
/***********************************************************************
// error codes and prototypes
************************************************************************/
/* Error codes for the compression/decompression functions. Negative
* values are errors, positive values will be used for special but
* normal events.
*/
#define LZO_E_OK 0
#define LZO_E_ERROR (-1)
#define LZO_E_OUT_OF_MEMORY (-2) /* [lzo_alloc_func_t failure] */
#define LZO_E_NOT_COMPRESSIBLE (-3) /* [not used right now] */
#define LZO_E_INPUT_OVERRUN (-4)
#define LZO_E_OUTPUT_OVERRUN (-5)
#define LZO_E_LOOKBEHIND_OVERRUN (-6)
#define LZO_E_EOF_NOT_FOUND (-7)
#define LZO_E_INPUT_NOT_CONSUMED (-8)
#define LZO_E_NOT_YET_IMPLEMENTED (-9) /* [not used right now] */
#define LZO_E_INVALID_ARGUMENT (-10)
#define LZO_E_INVALID_ALIGNMENT (-11) /* pointer argument is not properly aligned */
#define LZO_E_OUTPUT_NOT_CONSUMED (-12)
#define LZO_E_INTERNAL_ERROR (-99)
#ifndef lzo_sizeof_dict_t
# define lzo_sizeof_dict_t ((unsigned)sizeof(lzo_bytep))
#endif
/* lzo_init() should be the first function you call.
* Check the return code !
*
* lzo_init() is a macro to allow checking that the library and the
* compiler's view of various types are consistent.
*/
#define lzo_init() __lzo_init_v2(LZO_VERSION,(int)sizeof(short),(int)sizeof(int),\
(int)sizeof(long),(int)sizeof(lzo_uint32_t),(int)sizeof(lzo_uint),\
(int)lzo_sizeof_dict_t,(int)sizeof(char *),(int)sizeof(lzo_voidp),\
(int)sizeof(lzo_callback_t))
LZO_EXTERN(int) __lzo_init_v2(unsigned,int,int,int,int,int,int,int,int,int);
/* version functions (useful for shared libraries) */
LZO_EXTERN(unsigned) lzo_version(void);
LZO_EXTERN(const char *) lzo_version_string(void);
LZO_EXTERN(const char *) lzo_version_date(void);
LZO_EXTERN(const lzo_charp) _lzo_version_string(void);
LZO_EXTERN(const lzo_charp) _lzo_version_date(void);
/* string functions */
LZO_EXTERN(int)
lzo_memcmp(const lzo_voidp a, const lzo_voidp b, lzo_uint len);
LZO_EXTERN(lzo_voidp)
lzo_memcpy(lzo_voidp dst, const lzo_voidp src, lzo_uint len);
LZO_EXTERN(lzo_voidp)
lzo_memmove(lzo_voidp dst, const lzo_voidp src, lzo_uint len);
LZO_EXTERN(lzo_voidp)
lzo_memset(lzo_voidp buf, int c, lzo_uint len);
/* checksum functions */
LZO_EXTERN(lzo_uint32_t)
lzo_adler32(lzo_uint32_t c, const lzo_bytep buf, lzo_uint len);
LZO_EXTERN(lzo_uint32_t)
lzo_crc32(lzo_uint32_t c, const lzo_bytep buf, lzo_uint len);
LZO_EXTERN(const lzo_uint32_tp)
lzo_get_crc32_table(void);
/* misc. */
LZO_EXTERN(int) _lzo_config_check(void);
typedef union {
lzo_voidp a00; lzo_bytep a01; lzo_uint a02; lzo_xint a03; lzo_uintptr_t a04;
void *a05; unsigned char *a06; unsigned long a07; size_t a08; ptrdiff_t a09;
#if defined(lzo_int64_t)
lzo_uint64_t a10;
#endif
} lzo_align_t;
/* align a char pointer on a boundary that is a multiple of 'size' */
LZO_EXTERN(unsigned) __lzo_align_gap(const lzo_voidp p, lzo_uint size);
#define LZO_PTR_ALIGN_UP(p,size) \
((p) + (lzo_uint) __lzo_align_gap((const lzo_voidp)(p),(lzo_uint)(size)))
/***********************************************************************
// deprecated macros - only for backward compatibility
************************************************************************/
/* deprecated - use 'lzo_bytep' instead of 'lzo_byte *' */
#define lzo_byte unsigned char
/* deprecated type names */
#define lzo_int32 lzo_int32_t
#define lzo_uint32 lzo_uint32_t
#define lzo_int32p lzo_int32_t __LZO_MMODEL *
#define lzo_uint32p lzo_uint32_t __LZO_MMODEL *
#define LZO_INT32_MAX LZO_INT32_C(2147483647)
#define LZO_UINT32_MAX LZO_UINT32_C(4294967295)
#if defined(lzo_int64_t)
#define lzo_int64 lzo_int64_t
#define lzo_uint64 lzo_uint64_t
#define lzo_int64p lzo_int64_t __LZO_MMODEL *
#define lzo_uint64p lzo_uint64_t __LZO_MMODEL *
#define LZO_INT64_MAX LZO_INT64_C(9223372036854775807)
#define LZO_UINT64_MAX LZO_UINT64_C(18446744073709551615)
#endif
/* deprecated types */
typedef union { lzo_bytep a; lzo_uint b; } __lzo_pu_u;
typedef union { lzo_bytep a; lzo_uint32_t b; } __lzo_pu32_u;
/* deprecated defines */
#if !defined(LZO_SIZEOF_LZO_UINT)
# define LZO_SIZEOF_LZO_UINT LZO_SIZEOF_LZO_INT
#endif
#if defined(LZO_CFG_COMPAT)
#define __LZOCONF_H 1
#if defined(LZO_ARCH_I086)
# define __LZO_i386 1
#elif defined(LZO_ARCH_I386)
# define __LZO_i386 1
#endif
#if defined(LZO_OS_DOS16)
# define __LZO_DOS 1
# define __LZO_DOS16 1
#elif defined(LZO_OS_DOS32)
# define __LZO_DOS 1
#elif defined(LZO_OS_WIN16)
# define __LZO_WIN 1
# define __LZO_WIN16 1
#elif defined(LZO_OS_WIN32)
# define __LZO_WIN 1
#endif
#define __LZO_CMODEL /*empty*/
#define __LZO_DMODEL /*empty*/
#define __LZO_ENTRY __LZO_CDECL
#define LZO_EXTERN_CDECL LZO_EXTERN
#define LZO_ALIGN LZO_PTR_ALIGN_UP
#define lzo_compress_asm_t lzo_compress_t
#define lzo_decompress_asm_t lzo_decompress_t
#endif /* LZO_CFG_COMPAT */
#ifdef __cplusplus
} /* extern "C" */
#endif
#endif /* already included */
/* vim:set ts=4 sw=4 et: */
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+106
View File
@@ -0,0 +1,106 @@
/* minilzo.h -- mini subset of the LZO real-time data compression library
This file is part of the LZO real-time data compression library.
Copyright (C) 1996-2017 Markus Franz Xaver Johannes Oberhumer
All Rights Reserved.
The LZO library is free software; you can redistribute it and/or
modify it under the terms of the GNU General Public License as
published by the Free Software Foundation; either version 2 of
the License, or (at your option) any later version.
The LZO library is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with the LZO library; see the file COPYING.
If not, write to the Free Software Foundation, Inc.,
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
Markus F.X.J. Oberhumer
<markus@oberhumer.com>
http://www.oberhumer.com/opensource/lzo/
*/
/*
* NOTE:
* the full LZO package can be found at
* http://www.oberhumer.com/opensource/lzo/
*/
#ifndef __MINILZO_H_INCLUDED
#define __MINILZO_H_INCLUDED 1
#define MINILZO_VERSION 0x20a0 /* 2.10 */
#if defined(__LZOCONF_H_INCLUDED)
# error "you cannot use both LZO and miniLZO"
#endif
/* internal Autoconf configuration file - only used when building miniLZO */
#ifdef MINILZO_HAVE_CONFIG_H
# include <config.h>
#endif
#include <limits.h>
#include <stddef.h>
#ifndef __LZODEFS_H_INCLUDED
#include "lzodefs.h"
#endif
#undef LZO_HAVE_CONFIG_H
#include "lzoconf.h"
#if !defined(LZO_VERSION) || (LZO_VERSION != MINILZO_VERSION)
# error "version mismatch in header files"
#endif
#ifdef __cplusplus
extern "C" {
#endif
/***********************************************************************
//
************************************************************************/
/* Memory required for the wrkmem parameter.
* When the required size is 0, you can also pass a NULL pointer.
*/
#define LZO1X_MEM_COMPRESS LZO1X_1_MEM_COMPRESS
#define LZO1X_1_MEM_COMPRESS ((lzo_uint32_t) (16384L * lzo_sizeof_dict_t))
#define LZO1X_MEM_DECOMPRESS (0)
/* compression */
LZO_EXTERN(int)
lzo1x_1_compress ( const lzo_bytep src, lzo_uint src_len,
lzo_bytep dst, lzo_uintp dst_len,
lzo_voidp wrkmem );
/* decompression */
LZO_EXTERN(int)
lzo1x_decompress ( const lzo_bytep src, lzo_uint src_len,
lzo_bytep dst, lzo_uintp dst_len,
lzo_voidp wrkmem /* NOT USED */ );
/* safe decompression with overrun testing */
LZO_EXTERN(int)
lzo1x_decompress_safe ( const lzo_bytep src, lzo_uint src_len,
lzo_bytep dst, lzo_uintp dst_len,
lzo_voidp wrkmem /* NOT USED */ );
#ifdef __cplusplus
} /* extern "C" */
#endif
#endif /* already included */
/* vim:set ts=4 sw=4 et: */
Vendored Submodule
+1
+467
View File
@@ -0,0 +1,467 @@
// net_e2e — drive the full client login flow against a REAL server:
// auth handshake -> CG_LOGIN3 -> GC_AUTH_SUCCESS
// -> game server: CG_LOGIN2 -> char list -> select_character -> PHASE_GAME
// -> pump a few seconds, dump entities / points / inventory / party.
//
// net_e2e [auth_host] [auth_port] [game_host] [game_port] [id] [pw] [char_index] [seconds]
// defaults: 192.168.21.203 11000 192.168.21.203 11011 admin 123456789 0 8
// MT_E2E_SWEEP=1 enables the one-session safe protocol matrix; add
// MT_E2E_UNSAFE=1 only when warp/dungeon probes are explicitly authorized.
//
// Mirrors M2Client::pump_auth/pump_game orchestration without the Godot layer.
#include "../src/net/auth_client.h"
#include "../src/net/game_client.h"
#include <chrono>
#include <cstdio>
#include <cstdlib>
#include <functional>
#include <string>
#include <thread>
using clock_t_ = std::chrono::steady_clock;
static double elapsed(clock_t_::time_point start) {
return std::chrono::duration_cast<std::chrono::milliseconds>(clock_t_::now() - start).count() /
1000.0;
}
int main(int argc, char **argv) {
std::string auth_host = argc > 1 ? argv[1] : "192.168.21.203";
uint16_t auth_port = argc > 2 ? (uint16_t)std::stoi(argv[2]) : 11000;
std::string game_host = argc > 3 ? argv[3] : "192.168.21.203";
uint16_t game_port = argc > 4 ? (uint16_t)std::stoi(argv[4]) : 11011;
std::string id = argc > 5 ? argv[5] : "admin";
std::string pw = argc > 6 ? argv[6] : "123456789";
int char_index = argc > 7 ? std::atoi(argv[7]) : 0;
double run_secs = argc > 8 ? std::atof(argv[8]) : 8.0;
std::printf("net_e2e -> auth %s:%u game %s:%u id=%s char=%d\n", auth_host.c_str(), auth_port,
game_host.c_str(), game_port, id.c_str(), char_index);
bool trace = std::getenv("MT_NET_TRACE") != nullptr;
// ---- phase 1: auth server ------------------------------------------------
mtnet::AuthClient auth(id, pw);
auth.set_wire_trace(trace);
if (!auth.connect(auth_host, auth_port)) {
std::printf("FAIL: auth connect(): %s\n", auth.last_error().c_str());
return 2;
}
auto start = clock_t_::now();
while (!auth.done() && elapsed(start) < 15.0) {
auth.process();
std::this_thread::sleep_for(std::chrono::milliseconds(15));
}
if (!auth.done() || !auth.success()) {
std::printf("FAIL: auth (%s)\n", auth.fail_reason().empty() ? auth.last_error().c_str()
: auth.fail_reason().c_str());
return 3;
}
uint32_t login_key = auth.login_key();
std::printf("[auth] OK login_key=0x%08X\n", login_key);
auth.disconnect();
// ---- phase 2: game server ---------------------------------------------
mtnet::GameClient game(id, login_key);
game.set_wire_trace(trace);
game.set_auto_enter_game(false); // this tool sends it on its own schedule
if (std::getenv("MT_NET_DUMP")) {
for (uint16_t h : {0x0205, 0x0206, 0x0207, 0x0209, 0x020A, 0x0214, 0x0215,
// round 2
0x021B, 0x0519, 0x051A, 0x051B, 0x0730, 0x0A20, 0x0514, 0x0A30,
0x0A31, 0x0A50, 0x0912, 0x0603, 0x0304, 0x0307, 0x0217, 0x0216,
0x0410, 0x0413, 0x0A11, 0x0A12, 0x0A13,
// post-2026-08-30: create/delete, dragon soul, mall
0x020C, 0x020D, 0x020E, 0x020F, 0x051F, 0x0841, 0x0842, 0x0843, 0x0109}) {
game.dump_header(h);
}
}
const bool e2e_charcreate = std::getenv("MT_E2E_CHARCREATE") != nullptr;
const bool e2e_myshop = std::getenv("MT_E2E_MYSHOP") != nullptr;
const bool e2e_cube = std::getenv("MT_E2E_CUBE") != nullptr;
const bool e2e_sweep = std::getenv("MT_E2E_SWEEP") != nullptr;
const bool e2e_unsafe = std::getenv("MT_E2E_UNSAFE") != nullptr;
if (!game.connect(game_host, game_port)) {
std::printf("FAIL: game connect(): %s\n", game.last_error().c_str());
return 4;
}
start = clock_t_::now();
bool selected = false;
double select_t = 0;
bool tried_empire = false;
double loading_t = 0;
bool enter_sent = false;
int last_phase = -1;
while (elapsed(start) < 30.0) {
if (game.phase() == mtnet::PHASE_LOADING && loading_t == 0) {
loading_t = elapsed(start);
}
// give the server ~1.5s to finish the spawn burst, then say we're ready
if (game.phase() == mtnet::PHASE_LOADING && !enter_sent && loading_t > 0 &&
elapsed(start) - loading_t > 1.5) {
std::printf("[game] send CG_ENTERGAME (%.1fs into LOADING)\n", elapsed(start) - loading_t);
game.send_enter_game();
enter_sent = true;
}
game.world().set_now((uint32_t)(elapsed(start) * 1000.0));
game.process();
game.world().tick();
if ((int)game.phase() != last_phase) {
last_phase = (int)game.phase();
std::printf("[game] phase = %d (t=%.1fs)\n", last_phase, elapsed(start));
}
if (game.state() == mtnet::NetStream::State::Offline) {
std::printf("FAIL: game server dropped us (%s) at phase %d\n", game.last_error().c_str(),
(int)game.phase());
return 8;
}
if (game.char_list_ready() && !selected) {
std::printf("[game] char list: %zu slot(s) (empire byte=%d seen=%d)\n",
game.chars().size(), game.empire(), (int)game.empire_seen());
for (const auto &c : game.chars()) {
std::printf(" [%d] %-16s job=%d lv=%d (%d,%d)\n", c.index, c.name.c_str(),
c.job, c.level, c.x, c.y);
}
if (game.chars().empty()) {
std::printf("FAIL: no characters on the account\n");
return 5;
}
int idx = game.chars()[0].index;
for (const auto &c : game.chars()) {
if (c.index == char_index) {
idx = c.index;
}
}
// --- non-destructive create test: CG_CHARACTER_CREATE on an OCCUPIED
// slot must come back as GC_PLAYER_CREATE_FAILURE (nothing mutated).
if (e2e_charcreate) {
std::printf("[e2e] CG_CHARACTER_CREATE on occupied slot %d (expect FAILURE)\n", idx);
game.create_character(idx, "ZzTestName", 0, 0, 4, 3, 6, 3);
double t0 = elapsed(start);
bool got = false;
while (elapsed(start) - t0 < 3.0 && !got) {
game.process();
for (const auto &ev : game.drain_char_events()) {
using K = mtnet::GameClient::CharEvent::Kind;
if (ev.kind == K::CreateFail) {
std::printf("[e2e] -> GC_PLAYER_CREATE_FAILURE type=%d (struct OK)\n",
ev.fail_type);
got = true;
} else if (ev.kind == K::CreateOk) {
std::printf("[e2e] !! GC_PLAYER_CREATE_SUCCESS on occupied slot "
"(slot=%d) — unexpected, check slot choice\n",
ev.slot);
got = true;
}
}
std::this_thread::sleep_for(std::chrono::milliseconds(20));
}
if (!got) {
std::printf("[e2e] !! no create result in 3s (last_unknown=0x%04X) — "
"CG_CHARACTER_CREATE layout may be wrong\n",
game.last_unknown_header());
}
}
std::printf("[game] select_character(%d) -> %s\n", idx,
game.select_character(idx) ? "sent" : "SEND FAILED");
selected = true;
select_t = elapsed(start);
}
// stuck at SELECT >3s after selecting: try an explicit empire choice + reselect
if (selected && !tried_empire && game.phase() == mtnet::PHASE_SELECT &&
elapsed(start) - select_t > 3.0) {
std::printf("[game] still at SELECT %.1fs after select; sending CG_EMPIRE(1) + reselect\n",
elapsed(start) - select_t);
game.send_empire(1);
std::this_thread::sleep_for(std::chrono::milliseconds(100));
game.process();
game.select_character(char_index);
tried_empire = true;
}
if (game.failed()) {
std::printf("FAIL: game (%s)\n", game.fail_reason().c_str());
return 6;
}
if (game.in_game()) {
break;
}
std::this_thread::sleep_for(std::chrono::milliseconds(15));
}
if (!game.in_game()) {
std::printf("FAIL: never reached PHASE_GAME (phase=%d, last_unknown=0x%04X)\n",
(int)game.phase(), game.last_unknown_header());
return 7;
}
std::printf("[game] IN GAME (t=%.1fs)\n", elapsed(start));
// ---- phase 2.25: one-session protocol sweep ---------------------------
// These are deliberately sent one at a time with a short pump between them.
// A live socket only proves that the server accepted/framed the packet; a
// state/event change is printed separately as stronger evidence. Destructive
// requests (warp/dungeon/change-name/item mutation) stay behind MT_E2E_UNSAFE.
if (e2e_sweep) {
std::printf("\n=== one-session protocol sweep (safe subset) ===\n");
int sweep_sent = 0;
int sweep_alive = 0;
int sweep_events = 0;
auto pump_probe = [&](double seconds) {
auto p0 = clock_t_::now();
while (elapsed(p0) < seconds) {
game.world().set_now((uint32_t)(12000 + elapsed(p0) * 1000.0));
game.process();
game.world().tick();
if (game.state() == mtnet::NetStream::State::Offline) {
break;
}
std::this_thread::sleep_for(std::chrono::milliseconds(20));
}
};
auto probe = [&](const char *name, uint16_t header, const std::function<bool()> &send,
const std::function<int()> &events = std::function<int()>()) {
const uint16_t unknown_before = game.last_unknown_header();
const bool sent = send();
if (sent) {
++sweep_sent;
}
pump_probe(0.45);
const bool alive = game.state() != mtnet::NetStream::State::Offline;
if (alive) {
++sweep_alive;
}
int event_count = events ? events() : 0;
sweep_events += event_count;
std::printf("[sweep] %-24s tx=0x%04X sent=%d alive=%d events=%d "
"unknown_before=0x%04X unknown_after=0x%04X\n",
name, header, (int)sent, (int)alive, event_count, unknown_before,
game.last_unknown_header());
};
const uint32_t self_vid = game.world().main_vid();
const mtnet::Entity *self = game.world().get(self_vid);
const int32_t self_x = self ? (int32_t)self->x : 0;
const int32_t self_y = self ? (int32_t)self->y : 0;
probe("CG_CHARACTER_POSITION", mtnet::CG_CHARACTER_POSITION,
[&]() { return game.send_character_position(0); });
mtnet::CGSyncPositionElement sync{};
sync.vid = self_vid;
sync.x = self_x;
sync.y = self_y;
probe("CG_SYNC_POSITION", mtnet::CG_SYNC_POSITION,
[&]() { return game.send_sync_positions({sync}); });
probe("CG_SCRIPT_SELECT_ITEM", mtnet::CG_SCRIPT_SELECT_ITEM,
[&]() { return game.send_script_select_item(0); },
[&]() { return (int)game.world().drain_scripts().size(); });
probe("CG_QUEST_CANCEL", mtnet::CG_QUEST_CANCEL,
[&]() { return game.send_quest_cancel(); },
[&]() { return (int)game.world().drain_quest_changes().size(); });
probe("CG_PARTY_USE_SKILL", mtnet::CG_PARTY_USE_SKILL,
[&]() { return game.send_party_use_skill(0, self_vid); });
probe("CG_FLY_TARGETING", mtnet::CG_FLY_TARGETING,
[&]() { return game.send_fly_targeting(self_vid, self_x, self_y); },
[&]() { return (int)game.world().drain_fly_target_cues().size(); });
probe("CG_ADD_FLY_TARGETING", mtnet::CG_ADD_FLY_TARGETING,
[&]() { return game.send_add_fly_targeting(self_vid, self_x, self_y); },
[&]() { return (int)game.world().drain_fly_target_cues().size(); });
probe("CG_FISHING", mtnet::CG_FISHING,
[&]() { return game.send_fishing(0); },
[&]() { return (int)game.world().drain_fishing_events().size(); });
probe("CG_SHOOT", mtnet::CG_SHOOT,
[&]() { return game.send_shoot(0); });
probe("CG_USE_SKILL", mtnet::CG_USE_SKILL,
[&]() { return game.send_use_skill(0, self_vid); });
if (e2e_unsafe) {
probe("CG_DUNGEON (unsafe)", mtnet::CG_DUNGEON,
[&]() { return game.send_dungeon(); },
[&]() { return (int)game.world().drain_dungeon_events().size(); });
probe("CG_WARP (unsafe)", mtnet::CG_WARP,
[&]() { return game.send_warp(); });
} else {
std::printf("[sweep] CG_DUNGEON/CG_WARP skipped (set MT_E2E_UNSAFE=1)\n");
}
std::printf("[sweep] summary sent=%d alive_after_probe=%d observed_events=%d "
"still_connected=%d\n",
sweep_sent, sweep_alive, sweep_events,
(int)(game.state() != mtnet::NetStream::State::Offline));
}
// ---- phase 2.5: exercise post-2026-08-30 protocols -------------------
std::printf("\n=== post-2026-08-30 protocol probes ===\n");
std::printf("[e2e] GC_EMPIRE: empire byte=%d seen=%d\n", game.empire(),
(int)game.empire_seen());
// cube: /cube rList <npc> is a plain chat command; server only answers when
// the player is actually at a cube NPC, so this mostly proves the send path.
if (e2e_cube) {
uint32_t cube_npc = 20383; // common blacksmith/cube NPC vnum
std::printf("[e2e] send '/cube rList %u'\n", cube_npc);
game.send_cube_result_list(cube_npc);
}
if (e2e_myshop) {
// open a 1-item shop from inventory cell 0 at a silly price, then close.
std::vector<mtnet::MyShopItem> items;
bool have_item = false;
for (int c = 0; c < mtnet::INVENTORY_MAX_NUM && !have_item; ++c) {
const mtnet::Item &it = game.world().inv_slot(c);
if (!it.empty()) {
mtnet::MyShopItem e{};
e.vnum = it.vnum;
e.count = it.count ? it.count : 1;
e.pos = {mtnet::WINDOW_INVENTORY, (uint16_t)c};
e.price = 99999999;
e.display_pos = 0;
items.push_back(e);
have_item = true;
std::printf("[e2e] CG_MYSHOP: 1 item (vnum=%u inv_cell=%d) price=99999999\n",
e.vnum, c);
}
}
if (!have_item) {
std::printf("[e2e] CG_MYSHOP: inventory empty, opening a 0-item shop\n");
}
game.send_open_private_shop("e2e test shop", items);
}
// pump ~3.5s so any server push (guild skill/war, shop broadcast) lands
{
auto p0 = clock_t_::now();
while (elapsed(p0) < 3.5) {
game.world().set_now((uint32_t)(15000 + elapsed(p0) * 1000.0));
game.process();
game.world().tick();
if (game.state() == mtnet::NetStream::State::Offline) {
std::printf("[e2e] !! DROPPED during probe pump: %s\n", game.last_error().c_str());
break;
}
std::this_thread::sleep_for(std::chrono::milliseconds(20));
}
}
{
const mtnet::GuildSkillState &gs = game.world().guild_skill();
std::printf("[e2e] GUILD_GC_SKILL_INFO: valid=%d skill_point=%d guild_point=%d/%d "
"levels=[",
(int)gs.valid, gs.skill_point, gs.guild_point, gs.max_guild_point);
for (int i = 0; i < mtnet::GUILD_SKILL_MAX_NUM; ++i) {
std::printf("%d%s", gs.levels[i], i + 1 < mtnet::GUILD_SKILL_MAX_NUM ? "," : "");
}
std::printf("]\n");
const mtnet::GuildWarStatus &gw = game.world().guild_war();
std::printf("[e2e] GUILD_GC_WAR: state=%d type=%d opp_guild=%u active GvG pairs=%zu\n",
gw.state, gw.type, gw.opp_guild_id, game.world().guild_wars().size());
}
if (e2e_myshop) {
std::printf("[e2e] CG_MYSHOP: closing shop (SHOP_CG_END)\n");
game.send_close_private_shop();
auto p0 = clock_t_::now();
while (elapsed(p0) < 1.5) {
game.process();
std::this_thread::sleep_for(std::chrono::milliseconds(20));
}
for (const auto &err : game.world().drain_shop_errors()) {
std::printf("[e2e] shop error: %s\n", err.c_str());
}
}
std::printf("[e2e] cube state: open=%d npc=%u recipes=%zu results=%zu\n",
(int)game.world().cube().open, game.world().cube().npc_vnum,
game.world().cube().recipes.size(), game.world().cube().results.size());
std::printf("[e2e] mall state: open=%d size=%d\n", (int)game.world().mall_open(),
game.world().mall_size());
std::printf("[e2e] still connected after probes: %s\n",
game.state() == mtnet::NetStream::State::Offline ? "NO (dropped)" : "yes");
// ---- phase 3: observe + poke (move / attack with zero CRC) ---------
auto game_start = clock_t_::now();
bool poked = false;
while (elapsed(game_start) < run_secs) {
game.world().set_now((uint32_t)(20000 + elapsed(game_start) * 1000.0));
game.process();
game.world().tick();
if (game.state() == mtnet::NetStream::State::Offline) {
std::printf("[game] disconnected after %.1fs in game: %s\n", elapsed(game_start),
game.last_error().c_str());
break;
}
// 2s in: send a move + an attack (crc fields 0) and see if we survive
if (!poked && elapsed(game_start) > 2.0) {
const mtnet::Entity *me = game.world().get(game.world().main_vid());
int32_t x = me ? (int32_t)me->x : 0, y = me ? (int32_t)me->y : 0;
game.send_move(mtnet::FUNC_MOVE, 0, 0, x + 100, y, 2000);
game.send_attack(0, game.world().main_vid());
std::printf("[game] sent CG_MOVE + CG_ATTACK (crc=0) at +2s\n");
poked = true;
}
std::this_thread::sleep_for(std::chrono::milliseconds(20));
}
if (game.state() != mtnet::NetStream::State::Offline) {
std::printf("[game] still connected at +%.0fs (zero-CRC attack accepted)\n", run_secs);
}
mtnet::EntityStore &w = game.world();
std::printf("\n=== snapshot after %.0fs in game ===\n", run_secs);
std::printf("main vid: %u\n", w.main_vid());
if (const mtnet::Entity *me = w.get(w.main_vid())) {
std::printf(" name=%s race=%u pos=(%.0f,%.0f,%.0f) hp=%d/%d level=%d\n", me->name.c_str(),
me->race, me->x, me->y, me->z, me->hp, me->max_hp, me->level);
}
std::printf("entities in view: %zu\n", w.size());
int named = 0, mounted = 0;
for (uint32_t vid : w.vids()) {
const mtnet::Entity *e = w.get(vid);
if (!e->name.empty() && vid != w.main_vid()) {
if (named++ < 10) {
std::printf(" NAMED vid=%-7u race=%-5u lvl=%-4d guild=%d pk=%d mount=%u \"%s\"\n",
vid, e->race, e->level, e->guild, e->pk_mode, e->mount_vnum,
e->name.c_str());
}
}
if (e->mount_vnum != 0) {
++mounted;
}
}
std::printf("named entities: %d mounted: %d\n", named, mounted);
const mtnet::PlayerPoints &p = w.points();
std::printf("points: hp=%d/%d sp=%d/%d level=%d exp=%d/%d gold=%d\n", p.hp(), p.max_hp(), p.sp(),
p.max_sp(), p.level(), p.exp(), p.next_exp(), p.gold());
int inv_n = 0;
for (int c = 0; c < mtnet::INVENTORY_MAX_NUM; ++c) {
if (!w.inv_slot(c).empty()) {
++inv_n;
}
}
std::printf("inventory: %d non-empty slot(s)\n", inv_n);
std::printf("party: %zu member(s) friends: %zu\n", w.party_pids().size(), w.friends().size());
int skn = 0, qsn = 0;
for (int i = 0; i < mtnet::SKILL_MAX_NUM; ++i) {
if (w.skill_level(i) > 0) {
++skn;
}
}
for (int i = 0; i < mtnet::QUICKSLOT_MAX_NUM; ++i) {
if (w.quickslot(i).type != 0) {
++qsn;
}
}
std::printf("skills known: %d quickslots set: %d\n", skn, qsn);
const mtnet::GuildState &gld = w.guild();
if (gld.in_guild) {
std::printf("guild: \"%s\" lvl %d members %d/%d gold %u (%zu in list)\n",
gld.name.c_str(), gld.level, gld.member_count, gld.max_member_count, gld.gold,
w.guild_members().size());
} else {
std::printf("guild: (none)\n");
}
for (int i = 0; i < mtnet::SKILL_MAX_NUM && skn > 0; ++i) {
if (w.skill_level(i) > 0) {
std::printf(" skill %d: lvl %d master %d\n", i, w.skill_level(i), w.skill_master(i));
if (--skn == 0 || i > 60) {
break;
}
}
}
std::printf("\nRESULT: e2e OK — reached PHASE_GAME and pumped %.0fs\n", run_secs);
return 0;
}
+77
View File
@@ -0,0 +1,77 @@
// net_probe — connect to a Metin2 auth server, run the libsodium handshake and
// the CG_LOGIN3 exchange, print what happened. Purely diagnostic.
//
// net_probe <host> <port> <id> <pw> (defaults: 192.168.21.203 11000 admin 123456789)
#include "../src/net/auth_client.h"
#include <chrono>
#include <cstdio>
#include <string>
#include <thread>
int main(int argc, char **argv) {
std::string host = argc > 1 ? argv[1] : "192.168.21.203";
uint16_t port = argc > 2 ? static_cast<uint16_t>(std::stoi(argv[2])) : 11000;
std::string id = argc > 3 ? argv[3] : "admin";
std::string pw = argc > 4 ? argv[4] : "123456789";
std::printf("net_probe -> %s:%u id=%s\n", host.c_str(), port, id.c_str());
mtnet::AuthClient client(id, pw);
if (!client.connect(host, port)) {
std::printf("connect() failed: %s\n", client.last_error().c_str());
return 2;
}
using clock = std::chrono::steady_clock;
auto start = clock::now();
auto last_state = mtnet::NetStream::State::Offline;
bool reported_online = false, reported_cipher = false, reported_login = false;
while (std::chrono::duration_cast<std::chrono::seconds>(clock::now() - start).count() < 15) {
client.process();
if (client.state() != last_state) {
const char *s = client.state() == mtnet::NetStream::State::Connecting ? "Connecting"
: client.state() == mtnet::NetStream::State::Online ? "Online"
: "Offline";
std::printf("[state] %s\n", s);
last_state = client.state();
}
if (!reported_online && client.state() == mtnet::NetStream::State::Online) {
std::printf("[tcp] connected\n");
reported_online = true;
}
if (!reported_cipher && client.handshaked()) {
std::printf("[handshake] cipher activated (KX ok)\n");
reported_cipher = true;
}
if (!reported_login && client.sent_login()) {
std::printf("[auth] PHASE_AUTH reached, CG_LOGIN3 sent\n");
reported_login = true;
}
if (client.done()) {
break;
}
if (client.state() == mtnet::NetStream::State::Offline && reported_online) {
std::printf("[net] disconnected: %s\n", client.last_error().c_str());
break;
}
std::this_thread::sleep_for(std::chrono::milliseconds(20));
}
if (client.done() && client.success()) {
std::printf("RESULT: auth OK, login_key=0x%08X\n", client.login_key());
return 0;
}
if (client.done()) {
std::printf("RESULT: auth failed (%s)\n",
client.fail_reason().empty() ? client.last_error().c_str()
: client.fail_reason().c_str());
return 1;
}
std::printf("RESULT: timed out. last_error=%s online=%d cipher=%d login_sent=%d\n",
client.last_error().c_str(), (int)reported_online, (int)reported_cipher,
(int)reported_login);
return 3;
}
+94
View File
@@ -0,0 +1,94 @@
// packtool — cross-platform replacement for the fork's PackMaker.exe.
// packtool pack <folder> <out.epk> [--encrypt]
// packtool unpack <in.epk> <out_folder>
// packtool list <in.epk>
#include "../src/pack/eterpack.h"
#include <cstdio>
#include <cstring>
#include <filesystem>
#include <fstream>
#include <string>
#include <vector>
namespace fs = std::filesystem;
using namespace mtpack;
static std::vector<uint8_t> slurp(const fs::path &p) {
std::ifstream f(p, std::ios::binary);
return {std::istreambuf_iterator<char>(f), std::istreambuf_iterator<char>()};
}
static int do_pack(const char *folder, const char *out, bool enc) {
fs::path root(folder);
std::vector<InputFile> files;
for (auto &e : fs::recursive_directory_iterator(root)) {
if (!e.is_regular_file()) {
continue;
}
std::string rel = fs::relative(e.path(), root).generic_string();
files.push_back({rel, slurp(e.path())});
}
std::string err;
if (!write_pack(out, files, enc, &err)) {
std::fprintf(stderr, "pack failed: %s\n", err.c_str());
return 1;
}
std::printf("packed %zu files -> %s%s\n", files.size(), out, enc ? " (encrypted)" : "");
return 0;
}
static int do_unpack(const char *in, const char *out_folder) {
EterPack pk;
std::string err;
if (!pk.open(in, &err)) {
std::fprintf(stderr, "open failed: %s\n", err.c_str());
return 1;
}
for (const auto &name : pk.names()) {
std::vector<uint8_t> data;
if (!pk.read(name, data, &err)) {
std::fprintf(stderr, "read %s: %s\n", name.c_str(), err.c_str());
return 1;
}
fs::path dst = fs::path(out_folder) / name;
fs::create_directories(dst.parent_path());
std::ofstream f(dst, std::ios::binary);
f.write(reinterpret_cast<const char *>(data.data()), static_cast<std::streamsize>(data.size()));
}
std::printf("unpacked %zu files -> %s\n", pk.count(), out_folder);
return 0;
}
static int do_list(const char *in) {
EterPack pk;
std::string err;
if (!pk.open(in, &err)) {
std::fprintf(stderr, "open failed: %s\n", err.c_str());
return 1;
}
std::printf("%zu entries, name-field=%d\n", pk.count(), pk.name_field());
for (const auto &n : pk.names()) {
std::printf(" %s\n", n.c_str());
}
return 0;
}
int main(int argc, char **argv) {
if (argc >= 4 && std::strcmp(argv[1], "pack") == 0) {
bool enc = argc >= 5 && std::strcmp(argv[4], "--encrypt") == 0;
return do_pack(argv[2], argv[3], enc);
}
if (argc == 4 && std::strcmp(argv[1], "unpack") == 0) {
return do_unpack(argv[2], argv[3]);
}
if (argc == 3 && std::strcmp(argv[1], "list") == 0) {
return do_list(argv[2]);
}
std::fprintf(stderr,
"usage:\n"
" packtool pack <folder> <out.epk> [--encrypt]\n"
" packtool unpack <in.epk> <out_folder>\n"
" packtool list <in.epk>\n");
return 2;
}
+43
View File
@@ -0,0 +1,43 @@
# msm / msa Metin2 M2 EterGrnLib/RaceManager
# m2map/* A1 SHINSOO-WORLD-RENDERING.md W0 godot
add_library(xr_formats STATIC
textscript.cpp # M2 T1: Metin2 token .msa / .msm
msm.cpp # base gr2 + RaceDataScript
msa.cpp # anim gr2 + duration + accumulation +
m2_tokvec.cpp # W0: LoadMultipleTextData Start/End token
m2_coord.cpp # W0: Metin2Godot / / BACKLOG I5
map_setting.cpp # W0: setting.txt
texture_set.cpp # W0: TextureSet .txt
area_data.cpp # W0: areadata / areaambiencedata / areaproperty
terrain_files.cpp # W0: height.raw / tile.raw / attr.atr / water.wtr
environment.cpp # W0: .msenvGroup/List
property.cpp # W0: .prb/.prt/.pre/.prd/.pra + CRC BACKLOG E13
asset_resolver.cpp # W0: -> BACKLOG G7
terrain_mesh.cpp # W1: heightmap -> /线/ + GetHeight
splat.cpp # W2: tile.raw -> 258x258 alphaRAW_GenerateSplat
spt.cpp # W4: .spt
)
add_library(xrender::formats ALIAS xr_formats)
target_include_directories(xr_formats PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
target_link_libraries(xr_formats PUBLIC xrender::libgr2)
target_compile_features(xr_formats PUBLIC cxx_std_20)
if(BUILD_TESTING)
add_executable(formats_msa_test tests/msa_test.cpp)
target_link_libraries(formats_msa_test PRIVATE xrender::formats)
add_test(NAME formats.msa_loop_data COMMAND formats_msa_test)
add_executable(formats_msm_test tests/msm_test.cpp)
target_link_libraries(formats_msm_test PRIVATE xrender::formats)
add_test(NAME formats.msm_hair COMMAND formats_msm_test)
add_executable(formats_map_test tests/map_formats_test.cpp)
target_link_libraries(formats_map_test PRIVATE xrender::formats)
add_test(NAME formats.map_formats COMMAND formats_map_test)
# A1 live
if(DEFINED ENV{M2_ASSETS})
set_tests_properties(formats.map_formats PROPERTIES
ENVIRONMENT "M2_ASSETS=$ENV{M2_ASSETS}")
endif()
endif()

Some files were not shown because too many files have changed in this diff Show More