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:
+37
@@ -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
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -1,19 +1,28 @@
|
|||||||
# mtgodot-poc
|
# mtgodot-poc
|
||||||
|
|
||||||
「**Godot 4 + 自研资源 loader**」跨平台方案的渲染 Demo(route ①)。
|
「**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)· iOS(iPhone 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 曲线解码 bug,xrender-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、
|
shear,warrior 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-poc,2026-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 的格式逆向笔记(M0–M4 施工文档 + 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/ ← vendored:gr2 读取器(xrender::libgr2)
|
||||||
|
formats/ ← vendored:msa/msm/textscript
|
||||||
|
oracle/ ← vendored:Granny 真值工具(exe/dll 不入库)
|
||||||
|
tools/ ← vendored:gr2dump / gr2fuzz / oracle_diff
|
||||||
extension/
|
extension/
|
||||||
CMakeLists.txt mtgodot SHARED + godot-cpp + libgr2
|
CMakeLists.txt mtgodot SHARED + godot-cpp(consume 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 → Skeleton3D;selfcheck
|
||||||
|
gr2_bridge.{h,cpp} libgr2 POD → Godot 类型;basis + 单位换算
|
||||||
|
m2_material.{h,cpp} Metin2 风格 ShaderMaterial(mix / 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 重写)。
|
||||||
|
本仓库内部研究用途,不对外公开。
|
||||||
|
|||||||
Executable
+50
@@ -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
@@ -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"
|
||||||
Executable
+45
@@ -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' (看控制台日志)"
|
||||||
@@ -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
@@ -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 收尾(S–M)
|
||||||
|
|
||||||
|
| ID | 项 | 状态 | 规模 | 说明 / 依赖 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| A1 | 发型 mesh 实际挂接 | 🟡 | M | ✅ CPU 路径:`Metin2Model::set_hair_gr2()` + `_load_hair()` —— 加载发型 gr2,骨骼**按名重映射**到 base skeleton(warrior 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 pose,CPU/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.5,M;取代 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. 动画运行时(S–L)
|
||||||
|
|
||||||
|
| 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` → 屏闪 ColorRect;type 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` uniform(mix/skin)在 ~0.18s 内旧 1→0 / 新 0→1(`LODController::BlendRenderWithOneTexture`);距离阈值加 15% 迟滞防抖。⬜ GPU skin 路径的 LOD、受控帧核对。 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## D. 模型组合(S–L)
|
||||||
|
|
||||||
|
| ID | 项 | 状态 | 规模 | 说明 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| D1 | 多部件装配(身体 + 武器 + 发型 + 时装 + 翅膀,共享骨架) | ⬜ | L | `.msm` + 物品系统。核心:多 `MeshInstance3D` 绑同一骨架 + 骨名重映射;部位遮挡 / 隐藏规则。 |
|
||||||
|
| D2 | 武器模型(独立 `.gr2` 挂手骨) | 🟡 | M | ✅ 右手单持跑通(见 C5;rigid 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. 渲染功能(S–XL)
|
||||||
|
|
||||||
|
> `E7`–`E10`(特效 / 地形 / 水 / 植被)+ 地图加载 / 环境 / 第三人称场景的完整实施规格见
|
||||||
|
> [`SHINSOO-WORLD-RENDERING.md`](./SHINSOO-WORLD-RENDERING.md)(W0–W8 / R1 / R2,≈3.5–5.5 月起,
|
||||||
|
> Phase 2 三设备 bring-up 之后启动;自用,无正式门禁,W0 可提前并行)。对应的地图 / 环境 / 资产解析
|
||||||
|
> 工作项见下方 `E11`–`E13`,以及 `G7` / `H7` / `I5`。
|
||||||
|
|
||||||
|
| ID | 项 | 状态 | 规模 | 说明 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| E1 | 阴影调优 | 🟡 | S–M | `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 | tonemap(Filmic)+ adjustment 调色 + glow(intensity 0.35)+ SSAO(radius 1.2)+ MSAA 4× 已开。⬜ 按受控参考帧标定曝光/对比/饱和/glow/SSAO;TAA/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`) | ⏭ | L–XL | 真实资产是 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` 过真实 A1(4×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 CRC,136/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-up:Mac / 一加 13 / iPhone 16,自用,M–L)
|
||||||
|
|
||||||
|
| 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 | iOS(iPhone 16 / Metal) | 🟡 | M | ✅ 交叉编译打通:`./build-ios.sh` 出 `libmtgodot.ios.template_{debug,release}.a`(arm64 / minos 15.0),全依赖(libsodium/zstd/miniLZO/libgr2/formats/mtnet/godot-cpp)随之交叉编过;iOS 强制 extension 为 STATIC(不能 dlopen),`.gdextension` 加了 `[dependencies] ios.*` 归档清单。⬜ 剩 Godot 4.7 iOS 导出模板 + 导出 preset 链 `project/bin/ios/*.a` + 免费 provisioning 签名 + iPhone 16 真机跑 + Release/strip。预留 2–3 天。详见 `docs/PLATFORMS.md`。 |
|
||||||
|
| F2 | Android(一加 13 / Vulkan) | 🟡 | M | ✅ CMake 已接 `CMAKE_SYSTEM_NAME==Android`(SHARED `.so` + `arm64` 名字后缀);`./build-android.sh` 走 NDK 自带 `android.toolchain.cmake`(`arm64-v8a` / `android-24`)。⬜ 本机无 NDK —— 装 NDK 后 `./build-android.sh` 出 `.so`,再 Godot Android 导出 preset(Gradle)+ 一加 13 真机跑。**只验 Vulkan**,不做 Compatibility(GLES3) / 老机。详见 `docs/PLATFORMS.md`。 |
|
||||||
|
| 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 生命周期 | 🟡 | S–M | ✅ `project/app_lifecycle.gd`(AppLifecycle 节点):把 `NOTIFICATION_APPLICATION_PAUSED/RESUMED` / `FOCUS_IN/OUT` / `OS_MEMORY_WARNING` / `WM_GO_BACK_REQUEST` 收成信号,默认动作 = 后台暂停树 + 停 BGM + 降 `max_fps`,前台还原;`bind(m2client, audio)` 一键接。`M2Client`(C++):`_notification` 收 PAUSED/RESUMED → `suspend()/resume()`(挂起时不 pump socket),`reconnect()` 用存好的凭据重登;新信号 `suspended`/`resumed`。已接 `login.gd`(回前台若在游戏中自动重连)+ `world_demo.gd`。headless smoke(suspend/resume/reconnect 绑定 + 幂等 + 信号)通过,ctest 9/9,iOS 编过。渲染上下文丢失由 Godot Vulkan 自恢复,GPU 资源全 RenderingServer 托管,无需处理。⬜ 剩真机验证后台 30s→掐连接→回前台重连、iOS 内存告警实际触发。 |
|
||||||
|
| F6 | 打包 | ⬜ | S–M | macOS / iOS / Android export preset、资产打包策略、iOS 签名。自用,无商店上架。 |
|
||||||
|
| 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. 资产管线(S–M)
|
||||||
|
|
||||||
|
| ID | 项 | 状态 | 规模 | 说明 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| G1 | eterpack 读取器 | 🟡 | M–L | ✅ **本 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` CLI(pack/unpack/list,跨平台 PackMaker 替代)。`pack.roundtrip` CTest + 真实 45 文件 `textureset/` 目录明文/加密往返 `diff` 一致(含 CP949 中文名 + 嵌套目录)。⬜ 接进 `AssetResolver`(从包解析而非散文件)+ mmap 大包。 |
|
||||||
|
| G2 | 跨实例资产缓存 / 去重 | 🟡 | M | 角色:每实例重建。世界(W8):`Metin2World` 的 `PropertyRegistry` / `AssetResolver` / `StaticMeshCache`(建筑 gr2→mesh,A1 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 通盘过 | ⬜ | S–M | 现在软解 RGBA8 上传,GPU 端不压缩;移动端显存 / 带宽要压(BC7 / ASTC)。 |
|
||||||
|
| G6 | DXT 软解补 level-N mip | 🟡 | S | mtgodot `dxt.cpp` 只解 level 0(xrender 版有完整 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. 测试 / 工程(S–M)
|
||||||
|
|
||||||
|
| ID | 项 | 状态 | 规模 | 说明 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| H1 | 全语料过 Godot loader 的 fuzz | ⬜ | S–M | `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 覆盖面(S–M)
|
||||||
|
|
||||||
|
| ID | 项 | 状态 | 规模 | 说明 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| I1 | >4 骨权重 / 8-bone-weight | ✅ | S | `libgr2::Mesh` 已记录源 `BoneWeights` 槽数,`gr2fuzz` 纳入直方图。9166 文件中的 3118 个 skinned mesh 全部声明 4 槽,当前语料无需 `ARRAY_FLAG_USE_8_BONE_WEIGHTS`。新资产若出现其它槽数会在报告中显现。 |
|
||||||
|
| I2 | 多骨架 `.gr2` | ⬜ | S–M | 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`:一加 13(Vulkan)+ iPhone 16(Metal,静态链接)跑起来 + 三端一致 + 两台真机性能留档(旗舰基线)+ 生命周期 + 触屏相机。**无 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`(游戏相机)。
|
||||||
@@ -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 增量 30(EterGrnLib 对齐复核)**:查代码确认以下已落地,表内相关行同步。
|
||||||
|
> ① **材质 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` uniform,0.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 增量 38(ESC 系统菜单 + 游戏设置窗)**:
|
||||||
|
> ① **`system_menu_ui.gd`**(`systemdialog.py` 真 uiscript,thinboard 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/ER,EPointTypes 索引取自 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`(0x0B20–0x0B22) 的 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 增量 25(Atlas 大地图窗口)**:新增 `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.25–3.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、F1–F4 快捷栏切页和数字键 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`(0x0B20–0x0B22):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。
|
||||||
|
|
||||||
|
### M3:UI 与表现 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 完成。
|
||||||
@@ -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` CTest(KX / 挑战应答 + 篡改拒绝 / 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=MISS,tween 上浮淡出)+ 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`(真 socket:server 推 mall+cube,client 发 `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×1(vnum 149,price 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`(0x000D,70B)。`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`(0x0105,4 槽)** 角色列表(`[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 + handler,NPC「Old Man」(race 20009) 名字正确出。
|
||||||
|
- **`GC_CHARACTER_UPDATE`**(0x0209,38B) ✅ 之前只粗解 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`**(0x0214,1024B) —— **本来就对的**。真机 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`**(0x021B,1534B) ✅ 这才是本服发的技能包(**没发老的 0x021A**),之前
|
||||||
|
`get_skills()` 永远空、K 键技能窗没内容、快捷栏没法施放。加 `PlayerSkill{u8 master, u8 level,
|
||||||
|
u32 next_read}`(6B/条,fork 的 `time_t` 是 32 位)+ `GCSkillLevelNew` + handler。真机实测:33 个
|
||||||
|
技能全 lvl 40 master 3(GM 号)。`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×n,name[65])。真机 49 条 NPC,名字对
|
||||||
|
(服务器不 zero-pad name buffer,`strnlen` 已兜住)。
|
||||||
|
- **`GC_ITEM_UPDATE`**(0x0514,41B) / **`GC_CHAT`**(0x0603,10B 头) / **`GC_AFFECT_ADD`**(0x0A20,25B)
|
||||||
|
/ **`GC_SPECIAL_EFFECT`**(0x0A30,9B) / **`GC_QUEST_INFO`**(0x0912,7B) —— 逐字节核对,**全部已对**。
|
||||||
|
- **`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`(0x000F,len 4) → 跳过杂包到 `GC_RESPOND_CHANNELSTATUS`(0x0010) →
|
||||||
|
读 `int32 count` + count×`{i16 port, u8 status}`。status:0 关 / 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/` 目录、Metin2Model(40 骨 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`(77,job 是 **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 页 tab:STATUS/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 7,PythonPlayer.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` 真 uiscript,thinboard 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 换成 libsodium:key=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 3,specular 随强化 0→100 递增。`proto.item_mob` CTest 断言 11209 `values[3]==3` + `specular==100`。5748 items(stride 236)+ 1338 mobs(stride 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`(玩法)。
|
||||||
@@ -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 · 自用,无发布门禁
|
||||||
|
> **P0–P10 首版全部完成**;`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 网络补全 🌐"]
|
||||||
|
```
|
||||||
|
|
||||||
|
P1(UI 工具层)是 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 种 type(window/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`。⬜ 少见 type(ani_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)` → Dict(name / 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`(TSV:vnum→type/icon/model)。用于背包图标(替代 vnum 猜路径)+ 武器模型解析 |
|
||||||
|
| 本地玩家真模型 | M | ✅ | `ui/player_view.gd`(PlayerView,Node3D):`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/9,iOS 编过。
|
||||||
|
|
||||||
|
## 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/9,iOS 编过。
|
||||||
|
|
||||||
|
## 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 种 group:Particle(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_mode(dst=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 行表 / 引号枚举 / 嵌套 group;EffectPlayer:2 GPUParticles3D + amount + 球形发射 + 加法混合 + start_time meta + play()→emitting;registry:解析真 `.mse` 6 particle + spawn 挂载)。11 个 GDScript 测试套件全绿,ctest 9/9,iOS 编过。
|
||||||
|
|
||||||
|
**未完成**:`.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-idx:id0 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`(259,flat 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_idx;skill 窗行数 = 职业技能数 + +→`skill_up(1)` + Lv 5 显示;quickbar assign→activate→`cast_skill` 用正确 motion_idx + 冷却中不重发 + `cooldown_end` 后可再发)。12 个 GDScript 测试套件全绿,ctest 9/9,iOS 编过。
|
||||||
|
|
||||||
|
**未完成**:真弹道、加点等级公式 / 消耗校验(服务器管)。
|
||||||
|
被动 / 支援技能树 ✅(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 — 任务 / NPC(L,🌐)
|
||||||
|
|
||||||
|
| 任务 | 规模 | 状态 | 落点 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 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/9,iOS 编过。
|
||||||
|
|
||||||
|
**未完成**:`[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/CANCEL;GC 侧 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-31,body 复用 `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`(57,src/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/REFINE,cells 为背包格,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 / 错误 / END;exchange START/ITEM_ADD/ELK_ADD/ACCEPT/END;safebox 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`(18,x/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`(12,unix 秒 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_server;time set + dirty once;channel set;`GC_NPC_POSITION` 2 条 mark;marker create→update→delete;mount → `Entity.mount_vnum` + change);`p9_test.gd`(小地图:view + `CH` 角标 + `_to_map` 北朝上/东朝右/夹半径;world_time:正午 `day_fraction≈0.5` + 太阳亮,午夜 ≈0 + 太阳暗;weather:off→snow→rain(雨落更快)→off;warp 同服信号)。14 套 GDScript 测试全绿,ctest 9/9,项目导入干净。
|
||||||
|
|
||||||
|
**未完成**:大地图窗 + atlas 底图、地形贴花、钓鱼 / 副本、坐骑换模型、世界空间任务箭头、天气按地图驱动、跨服换图(P10)。
|
||||||
|
|
||||||
|
## P10 — 网络补全(M,🌐)
|
||||||
|
|
||||||
|
| 任务 | 规模 | 状态 | 落点 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **LOADING → PHASE_GAME**(真机端到端的最后一跳)| S | ✅ | `wire.h` `CGClientVersion`(0x000D,70B,filename+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/ER;EPointTypes 索引取 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`→GAME,SELECT 断线→LOGIN)。15 套 GDScript 测试全绿,ctest 9/9。**`net_e2e` 真机:auth → game → 选人 → LOADING → PHASE_GAME 全程打通。**
|
||||||
|
|
||||||
|
**真机字节校准 + 收尾(2026-08-30,已做)**:
|
||||||
|
- `GC_CHAR_ADD_INFO`(0x0207,97B) 补上 → 实体名/等级/公会/骑乘出(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 资产。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 里程碑
|
||||||
|
|
||||||
|
**P0–P10 首版全部完成**(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
@@ -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 1(macOS)基本完成,见 [`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 1:macOS**(当前)→ Phase 2:iOS · Android |
|
| 目标平台 | **Phase 1:macOS**(当前)→ Phase 2:Android(一加 13 / Vulkan)· iOS(iPhone 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)· iOS(iPhone 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 16(Metal)+ 一加 13(Vulkan)渲染与 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 描述 → ShaderMaterial(M2.5)
|
m2_material.{h,cpp} texture-stage 描述 → ShaderMaterial(M2.5)
|
||||||
libgr2/ submodule → ../xrender-poc/libgr2
|
(libgr2 / formats 在仓库根,顶层 CMake add_subdirectory 进来)
|
||||||
formats/ submodule → ../xrender-poc/formats(msa/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 macOS(Phase 1)
|
export_presets.cfg macOS(Phase 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 sky,M2.5 接入 | 成本近零,Mac 观感必要 |
|
| 阴影 / sky | Godot 内置 `DirectionalLight3D` shadow + `WorldEnvironment` procedural sky,M2.5 接入 | 成本近零,Mac 观感必要 |
|
||||||
| 资产来源 | 从 `m2dev-client-main/assets` 拷 **warrior + 另 2 种骨架** 的 gr2/dds 到 `project/assets/`,散文件 | 不接 eterpack(Phase 2 / M4) |
|
| 资产来源 | 从 `assets/` 取 **warrior + 另 2 种骨架** 的 gr2/dds 到 `project/assets/`,散文件 | 不接 eterpack(Phase 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`(届时再写)。
|
一加 13(Vulkan)+ iPhone 16(Metal)真机跑起来、三端一致、性能留档、生命周期。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` 预编译产物 |
|
| iOS:GDExtension 须静态链接进 app 二进制(`libgr2` 出 iOS arm64 `.a`),签名 / 模板脾气大 | 自用免费 provisioning 够用;预留 2–3 天 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 · **设备范围定死(自用)**:目标 = Mac(macOS / Metal)+ 一加 13(Android arm64 / Vulkan)+ iPhone 16(iOS 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 2;Godot 版本钉死 4.7.1 / godot-cpp 4.7。
|
- 2026-08-29 · 范围收敛为 **Phase 1 = macOS only**;新增 **M2.5 材质与观感保真** 作为 Phase 1 核心门禁;动画 A/B 决策推迟到 Phase 2;Godot 版本钉死 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 + BGRA8,level 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 + BGRA8,level 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
@@ -1,7 +1,8 @@
|
|||||||
# Phase 1 中期评审 — mtgodot-poc
|
# Phase 1 中期评审 — mtgodot-poc
|
||||||
|
|
||||||
> 日期:2026-08-29 · 平台:macOS(Apple M2)· Godot 4.7.1 · 对标 [`GODOT-POC-PLAN.md`](./GODOT-POC-PLAN.md) §00 Phase 1
|
> 日期:2026-08-29(评审后又做了一轮收尾,见 §4/§5 的「已做」标注)· 平台:macOS(Apple 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×light;opaque/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 个 surface(shaman 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`(两个 shader:blend_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 入口 |
|
||||||
|
| libgr2(vendored)新增 `gr2_material.cpp` + `Mesh::material_textures` | ~150 | `dump_materials()` + 逐 mesh MaterialBindings→贴图名 |
|
||||||
|
|
||||||
**耗时**:M0'–M2.5 一次会话内跑通。与计划 §05 估的 Phase 1「3.5–5 周单人」相比,**核心桥接远比预估轻** —— 因为 `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 里塞自研渲染器"那条路好。
|
- **移动端预判**:目标设备定为 Mac(Metal)+ 一加 13(Vulkan)+ iPhone 16(Metal),均现代硬件,**不涉及 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` 扫全 race:warrior 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` 走。shaman(1 mesh→2 surface)、assassin(17→18)实测各面贴图正确。 | 已做 |
|
||||||
| **性能数字被节流** | 后台窗口下 Godot 限 30fps,1–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 2:GPU 路径跑人群 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 2(iOS + 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.5–2 周):
|
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-5,CPU/GPU 蒙皮一致)。#3 是把已知的 upstream 曲线 bug 补上,不是 Godot route 的缺陷。
|
> Godot route 桥接层已验证正确:数值真值 = `oracle_diff`(libgr2 vs 真 Granny 2.9.12,23 用例 ≤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、粗估规模、排期。
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
**粗估**:把下面全部做到「目视等价」,单人 **≈ 3–6 个月**(不含 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` | S–M |
|
||||||
|
|
||||||
|
### 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.00–0.06 m;world_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` 分层 | S–M | E2 |
|
||||||
|
| 2.9 | 动画混合 / crossfade | ✅ **首版已做**:`Metin2AnimPlayer.blend_time`(默认 0.15s)。切 `anim_path` 时保留旧 clip 冻结在当前时间,每帧对旧/新 `sample_pose` 的 `world_pose` 逐骨 slerp(rot)+lerp(pos/scale)(smoothstep 权重),再 `skin = invWorld · world_blended` 重建;武器挂点跟混合后的手。shear 骨在 ≤blend_time 过渡内退化为无 shear(文档许可)。目视 wait→run 连续无 T-pose 闪 | ⬜ 受控帧核对过渡时长 | M(首版完成) | C2 |
|
||||||
|
| 2.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 | — | S–M(首版完成) |
|
||||||
|
| 3.5 | 地形 LOD / patch 裁剪 | 🟡 **首版已做**:`Metin2World.terrain_patches`(默认 4)—— 每区块地形拆成 N×N 个 `MeshInstance3D` patch(16 个,各 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–M(P1,首版完成) |
|
||||||
|
| 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 完成后再做 | 可选 S–M |
|
||||||
|
| 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-split,cascade 500m,bias 调过 | 逐场景标定 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.12,hdr 阈值 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 指回 MeshInstance3D;A1 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` 已进 CTest;golden 视觉门禁待接 | S |
|
||||||
|
| 9.5 | 目标帧验收配置 | 在 `ingame-shinsoo.json` 为天空/草地/道路/建筑/树/角色分别保存 ROI、metric、threshold;UI 窗口 rect/锚点初始容差为 1 px | S |
|
||||||
|
| 9.6 | 阈值冻结规则 | 同一机器连续渲染 5 次测噪声底;世界 ROI 阈值必须高于噪声并经一次人工签核后冻结。不得在回归失败时临时放宽阈值 | S |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 平台(SHINSOO 的前置,不是观感项但影响「能不能发」)
|
||||||
|
|
||||||
|
- Android(一加 13 / Vulkan)+ iOS(iPhone 16 / Metal,静态链接)bring-up:**一次都没跑过**。见 `BACKLOG` F1–F7。
|
||||||
|
- 所有新 shader(`SRC_TERRAIN` / `SRC_WATER` / `SRC_LEAF` / `SRC_SKIN`)在 Mobile renderer 下未验。
|
||||||
|
- 移动端性能预算(流式 + `MTGODOT_GPUSKIN` 人群)未测真机。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 建议推进顺序
|
||||||
|
|
||||||
|
**第 1 梯队(性价比最高,1–1.5 月)**
|
||||||
|
1. 0.1–0.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.2–6.5 完整 HUD
|
||||||
|
14. 10. 移动端 bring-up
|
||||||
|
|
||||||
|
**贯穿**:每个梯队做完过一次 9.2 的 ROI 门禁 + 人工签核,用数据说话,别靠感觉。
|
||||||
@@ -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, ~2–3 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
@@ -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.
|
||||||
@@ -0,0 +1,345 @@
|
|||||||
|
# Demo 开发方案 —— bgfx 渲染 assets 下的 gr2 资源
|
||||||
|
|
||||||
|
> 本文件把 [`PLAN.md`](./PLAN.md) 的 M0 + M1(+ M2 拉伸目标)落成一个**可演示的 demo** 的文件级施工图。
|
||||||
|
> demo 只做 macOS(Metal),跑通后 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、多部件装配、eterpack(demo 用散装文件)、iOS/Android(同代码后续换 toolchain)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2 · 演示用资产(具体文件,散装,无需解包)
|
||||||
|
|
||||||
|
| 用途 | 路径(相对 `m2dev-client-main/`) | 事实 |
|
||||||
|
|---|---|---|
|
||||||
|
| 主模型 | `assets/PC/ymir work/pc/warrior/warrior_cheongrin.gr2` | 92 770 B,gr2 格式 v6,8 section,**section 全 Oodle1 压缩**(实测)|
|
||||||
|
| 主贴图 | `assets/PC/ymir work/pc/warrior/warrior_cheongrin.dds` | **512×512 DXT3,5 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 §07(demo 阶段可先跳,用 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 · 模块与文件清单(职责 + 估行)
|
||||||
|
|
||||||
|
### libgr2(demo 子集,只读)
|
||||||
|
|
||||||
|
| 文件 | 职责 | 估行 | 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) |
|
||||||
|
|
||||||
|
**D0–D4 = 可交付的 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=Oodle1(Metin2 实测全 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 0;mip 链 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 网格应正常
|
||||||
|
|
||||||
|
# 渲染(D2–D4)
|
||||||
|
./build/app/xrender-demo
|
||||||
|
# 拖拽转视角;T 贴图;N 法线;W 线框;Space 播 dance_1(D5)
|
||||||
|
```
|
||||||
|
|
||||||
|
**演示成立的判据**:D0–D4 全绿 = "bgfx 能从 assets 的散装 gr2 + dds 渲出正确的带贴图 warrior,且有自检工具" —— 这就把 PLAN 的 M1 用一个能给人看的东西证了。D5 绿 = M2 的核心(曲线采样 + LBS)也站得住。
|
||||||
|
|
||||||
|
**下一步**(demo 之后):接 oracle(Windows dump)做 D5 的数值对拍 → 换 iOS/Android toolchain 跑 D2–D4 → 按 PLAN M3 补性能采集。
|
||||||
@@ -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(并行) |
|
||||||
|
| 判定周期 | ≈ 2–3 个月 → go / no-go |
|
||||||
|
| 复用 | EterBase · EterPack · EterImageLib · `MobileSource` 的 `.sc` 着色器草稿 |
|
||||||
|
| 自研 | libgr2(gr2 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` + 三份 toolchain:iOS(`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 Universal(bgfx 的 `bimg` / `texturec` 就能做)。
|
||||||
|
- **文件访问** — 直接用 `CMappedFile`,桌面走 mmap,Android 走 `AAsset_getBuffer`(MobileSource 已实现)。M0–M3 用散装文件,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 着色器基线**,M1–M2 直接改 |
|
||||||
|
| `vs_pdt.sc` / `fs_pdt.sc` | position-diffuse-texcoord,Metin2 最常用 FVF | 带顶点色的网格 |
|
||||||
|
| `vs_pc.sc` / `fs_pc.sc` | position-color,无纹理 | UI 图元 |
|
||||||
|
| `vs_pt.sc` / `pt2`、`fs_pt.sc` / `pt2` | position-texcoord,2D 图像/精灵 | UI 贴图 |
|
||||||
|
| `vs_terrain.sc` / `fs_terrain.sc` | 地形 splatting,很小 | 基本占位,PoC 不用 |
|
||||||
|
| **`texture_stage.sh`** | 把 D3D 固定功能 `SetTextureStageState` 完整翻译成 shader:COLOROP/ALPHAOP(MODULATE、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 数组或 texture,VS 里做 LBS)。这是 M2 的核心。
|
||||||
|
- `vs_pnt` 丢了 `a_color0`(Metin2 网格常带顶点色),要补。
|
||||||
|
- `v_texcoord1`(第二 UV,lightmap/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`(rigid,4016 mesh)、`PNT3322`(rigid 双 UV,309)、`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 静态渲染 — 2–3 周
|
||||||
|
|
||||||
|
- **交付** — sokol_app 窗口 + bgfx(Metal),渲染 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 真机 — 1–2 周(与 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.95–0.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 11(A13)+ Pixel 6(Mali-G78)+ 一台 Adreno 机(如 Redmi Note 系列),三种主流移动 GPU 架构各一。
|
||||||
|
- **指标** — 帧时间 p50 / p99(ms)、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 Universal(bgfx `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 实现或第三方 LZ(gr2 里的 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 周 | 合计 ≈ 6–8 周(libgr2 / oracle+验证台 / RHI+壳 三线并行) |
|
||||||
|
| M1 macOS 静态渲染(含 bgfx↔sokol_app 交接子门禁 2–4 天) | 2–3 周 | |
|
||||||
|
| M2 动画 + CPU 蒙皮(曲线解码是关键路径) | 2–3 周 | |
|
||||||
|
| M3 iOS + Android 真机 | 1–2 周 | |
|
||||||
|
| **到 go/no-go 结论** | **≈ 2–3 个月** | **≈ 6–8 周** |
|
||||||
|
| 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.list(M0 产出) 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) | 待 M1–M2 |
|
||||||
|
| 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 GPU(Adreno / Mali / PowerVR)× API level × 厂商 ROM。
|
||||||
|
7. **上架前检查** —— iOS 无私有 API、隐私清单、包大小;Android 64 位、target SDK、权限。
|
||||||
|
8. **回归防线** —— CI 每次改动跑快照回归 + 性能基准,防"某次改动让 Mali 上花屏"这类回归。
|
||||||
|
|
||||||
|
### 10.3 贯穿始终的一条原则
|
||||||
|
|
||||||
|
**Windows 客户端是唯一真值源。** 任何"三端自己看着对"都不算数,必须能和 Windows 逐帧 / 逐场景对拍。
|
||||||
|
这套 oracle 基础设施从 M0 就开始建(先只 dump 骨骼 + 顶点 + 单帧),移植阶段扩成整机录制回放 —— 是整个项目最值的一笔投资。
|
||||||
@@ -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) |
|
||||||
@@ -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-Full(T2/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.5–1 天 · **M** ≈ 2–4 天 · **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` 或 1(rigid)、`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 蒙皮顶点 **[S–M]**
|
||||||
|
|
||||||
|
- `GrannyNewMeshBinding(mesh, srcSkel, animSkel)` + `GrannyNewMeshDeformer(...)` + `GrannyDeformVertices(deformer, boneMatrixCount, worldPose4x4, vertexCount, srcVerts, dstVerts)`。
|
||||||
|
- 另用 `GrannyCopyMeshVertices(mesh, PNT332Type, rawBuf)` 读原始顶点作对照。
|
||||||
|
- 输出 mesh 0 的 `vertexCount` + 每顶点 `float[3]`(要的话加法线)。
|
||||||
|
- **AC**:
|
||||||
|
- [ ] bind pose(t=0 / identity)下,`GrannyDeformVertices` 的输出 == `GrannyCopyMeshVertices` 的原始顶点,`max‖Δ‖ < 1e-3`(T8 后收紧到 noise_floor)。
|
||||||
|
- [ ] 顶点数与 [M0 T6](./M0-gr2-reader.md) / Blender 一致。
|
||||||
|
- [ ] 走路动画某帧的顶点与层① 世界矩阵手算 LBS 的结果一致(自洽)。
|
||||||
|
|
||||||
|
### T6 · 确定态截图 harness **[M]** —— M1 视觉验证依赖
|
||||||
|
|
||||||
|
- **独立最小 D3D9 渲染器**(~200–300 行,**不依赖 EterGrnLib**):`CreateDevice` → `CreateRenderTarget` 离屏 → 用 T3 的世界矩阵 + T5 的蒙皮顶点画三角 → `GetRenderTargetData` → 存 PNG(PNG-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-Full(T2/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 渲染器 | 独立写 ~200–300 行离屏 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 状态记录:已做 / 延后 / 放弃
|
||||||
@@ -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 推进**:T1–T6、T7a、T8、T9a/b 的**代码实现**都不需要 oracle。
|
||||||
|
- **需要 oracle 的只有验收对拍**:门禁·主、T4/T6 的 count 对拍、T7b 的曲线结果对拍。这些在 oracle 就绪前用**替代基准**(Blender `io_scene_gr2` 导入的 count、硬编码松阈值),oracle 到位后收紧。
|
||||||
|
- **需要 T8 的噪声地板**(`test/noise_floor.json`,来自 [oracle](./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。容器 struct(header / section / fixup / typetree / transform)在 2.6–2.11 之间稳定,v6/v7 差异主要在 FileInfo 里装什么对象,不在容器。
|
||||||
|
|
||||||
|
## 约定
|
||||||
|
|
||||||
|
- **M0 全程用文件原始值,不加任何 basis fix / 单位缩放**。T5 自洽检查、与 oracle 对拍、gr2dump 输出,全在 raw 空间。坐标系转换(Granny art-tool basis → 左手 Y-up)是 [M1](./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** ≈ 2–4 天 · **L** ≈ 1 周+
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 任务分解
|
||||||
|
|
||||||
|
> 每个 T 下的"AC"是可勾选的验收标准(不是一句话)。带 *(oracle)* 的项在 oracle 就绪前用替代基准。
|
||||||
|
|
||||||
|
### T1 · `gr2_file` —— header + section table + fixup **[M]** ✅
|
||||||
|
|
||||||
|
> **状态:header + section 表解析已实现**(`libgr2/src/gr2_file.cpp`),`gr2dump --sections` 跑通全部 9166 个文件、0 崩溃。
|
||||||
|
> 剩下的是 **section 解压(Oodle1,见 T2)**和 **fixup 重定位**(需展开后的数据)。
|
||||||
|
|
||||||
|
**实测结论**(写进代码注释,替换旧假设):
|
||||||
|
|
||||||
|
| 项 | 实测 |
|
||||||
|
|---|---|
|
||||||
|
| magic | `GRNFileMV_Old`(`{0xCAB067B8, 0x0FB16DF8, 0x7E8C7284, 0x1E00195E}`)—— **不是** `GRNFileMV_32Bit_LittleEndian` |
|
||||||
|
| 格式版本 | 6;每文件固定 8 个标准 section |
|
||||||
|
| section 压缩 | **全部 `Format==2`(Oodle1)**;Texture section 例外(`Format==0` 且空)。`HeaderFormat`(=0)不是 section 压缩字段。|
|
||||||
|
| `total_size` 字段 | == 文件实际大小(9166/9166) |
|
||||||
|
| BitKnit | 全样本 0 个 |
|
||||||
|
| section 头 | `SectionArrayOffset` 相对 `grn_file_header` 起点(= magic 结构之后 32B);每 `grn_section` = 11×u32 = 44B |
|
||||||
|
|
||||||
|
- 逐 `grn_section`:`gr2_decompress` 展开到 `ExpandedDataSize`(T2,Oodle1)→ 遍历 `grn_pointer_fixup[]` 把 `FromOffset` 处的值改写成 `To`(section+offset) 的进程内指针 → 小端机跳过 `grn_mixed_marshalling_fixup`。
|
||||||
|
- **AC**(`warrior_cheongrin.gr2` = 92 770 B;`warrior_cheongrin_lod_01.gr2` = 68 012 B):
|
||||||
|
- [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 ≥ 1(body,可能 + 附属);`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-3;4 个 `>=1e-1` **经 oracle 证实 libgr2 正确**(assassin 对 Granny 3.1e-5),是自洽不变量本身对这几个双-root 资产不成立,非 libgr2 bug。
|
||||||
|
- **fuzz**:T9b 全量 9166 —— ✅ **零崩溃 + 零谓词失败**。
|
||||||
|
- **变体可实现**:✅ 无 BitKnit / 无 Oodle0;曲线全 `OldCurveType`,degree ≤ 2,T7b 全覆盖。不做的变体见 [`../../libgr2/README.md`](../../libgr2/README.md)「明确不做」(均 0 出现,坏输入干净报错不崩)。
|
||||||
|
- **产出**:✅ `test/{fuzz-report,m2-numeric,noise_floor}.json` + `test/assets.list` + `test/oracle_dumps/`。
|
||||||
|
- **noise_floor**:✅ `test/noise_floor.json`。同-DLL 确定性 = 0(byte-identical);跨实现地板 = mat 4.6e-5 / vtx 6.5e-5;跨版本一档待另一个 granny2.dll。
|
||||||
|
|
||||||
|
**M0 结论:全部门禁达成(含 vs 真 Granny 逐字段对拍)。**
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
- **bootstrap 关系**:M0 自身验证用硬编码引导集(`warrior_*` + 若干 zone);`assets.list` 产出后,M1+ 一律用它。
|
||||||
|
- **辅助**:`--gltf` 拖进 Blender 目视骨架拓扑 + bind pose 网格。glTF 证不了动画曲线解码(表达不了 ease / scale-shear / 常量轨道压缩)。
|
||||||
|
|
||||||
|
## 开工前要定的 TODO(跑起来才能定,非文档缺陷)
|
||||||
|
|
||||||
|
- **[T4/T5/T6 期望值] Blender 导入核对** —— M0 第一天用选定的 Blender 插件导入 `warrior_cheongrin.gr2`,记下真实的 skeleton / mesh / material / vertex / bone count,替换各 T 里 "大概率" 的占位期望值,写进 `test/README.md`。
|
||||||
|
- **[T7b] 曲线子类型清单** —— 具体做哪 1–2 种,要 T7a + T9c 的 `fuzz-report.json` 才知道。T7b 开工前 `gr2_anim.cpp` 先只做 `granny_curve.cpp` 的未压缩关键帧 + 线性插值。
|
||||||
|
- **[验证] Blender `io_scene_gr2` 插件** —— 有多个同名插件(SWTOR 版、Metin2 fork),轴向约定各异。M0 第一周:定一个、确认能导入 `warrior_cheongrin.gr2`、记录轴向约定到 `test/README.md`。定不下来 → 三方 tie-break 暂时只靠 oracle,或找第二个开源 gr2 库。
|
||||||
|
|
||||||
|
## 本步风险(从 PLAN §08 筛)
|
||||||
|
|
||||||
|
| 风险 | 状态 / 缓解 |
|
||||||
|
|---|---|
|
||||||
|
| **section 全是 Oodle1**(实测 9166),T2 从 [S] 变 [M] | **已解**:`oodle1.c` 端口自泄露 SDK,9166/9166 展开 == `ExpandedDataSize` |
|
||||||
|
| **T9b 加固吃掉大部分工期** | **已解**:9166 零崩溃 + 零谓词失败。实际 T9b 几乎没触发额外加固(类型树通用遍历 + 懒解引用 + 全程边界检查一次到位)|
|
||||||
|
| section 用 Oodle0 | **不适用**:全样本 0 个 |
|
||||||
|
| section 用 BitKnit | **不适用**:全样本 0 个 |
|
||||||
|
| 自描述类型树递归 / 未知类型 | **已解**:T3 通用遍历器不假设布局;全量 0 个未知成员类型 |
|
||||||
|
| 曲线子类型多样(B 样条拟合等) | **已解**:全部 `OldCurveType`,degree ∈ {0,1,2}、dim ∈ {0,3,4,9}。T7b 全覆盖(degree 3 全样本 0 个)|
|
||||||
|
| 非单位 scaleshear 普遍存在 | **确认普遍**(2267 skeleton / 13298 骨):组合公式已用完整 `R3·SS3`;通知 M2/M3 蒙皮走完整仿射 |
|
||||||
|
| `InverseWorld4x4` 读错但一致 | **已兜住**:T5 自洽检查全量跑,2013/2017 < 1e-3,4 个异常定位到辅助骨子树(见上)|
|
||||||
|
|
||||||
|
## DoD 清单
|
||||||
|
|
||||||
|
- [x] T1 header + section 表:`gr2dump --sections` 跑通全部 9166 个文件、0 崩溃、`total_size` 全对
|
||||||
|
- [x] T2 Oodle1 解压:9166/9166 全 section 展开 == `ExpandedDataSize`;内嵌路径串解压后干净可读(内容锚)
|
||||||
|
- [ ] T2 加强:与 `GrannyDecompressData` 逐字节对拍一次(oracle 就绪后)
|
||||||
|
- [x] T1 fixup:pointer 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-3(4 个异常见下)
|
||||||
|
- [x] T6 网格:`warrior_cheongrin` 5 mesh 全 `PNT332_Skinned`,顶点/索引/三角组自洽(`triFirst+triCount*3==indices`),bone-binding 0 悬空
|
||||||
|
- [x] T9a/b:9166 个 `.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 × 9155(magic `GRNFileMV_Old`)、**v7 × 11**(magic `GRNFileMV_32Bit_LittleEndian`,非 Old;含 `shaman_lord` / snow_dungeon zone / `warrior_rabbit1`)|
|
||||||
|
| section 压缩 | 非空 section 全 **Oodle1**(64162 段);每文件 1 个空 section(compression=none)。**无 Oodle0,无 BitKnit** |
|
||||||
|
| 顶点类型 | `PNT332` × 4016(rigid)、`PNT3322` × 309(rigid 双 UV)、`PNT332_Skinned` × 3118。**无 `PNT3322_Skinned`,无未知类型** |
|
||||||
|
| 曲线格式 | **全部是 `OldCurveType`**(Granny 2.4:`{Int32 Degree; RefToArray Knots; RefToArray Controls}`,无压缩变体、无 `curve2`/`CurveData` variant)。子类型 = (degree, dim):<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 用同一套。
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
# M1 · macOS 静态渲染
|
||||||
|
|
||||||
|
> 总纲:[`../PLAN.md`](../PLAN.md) §03 / §04 / §06。演示切片见 [`../DEMO-PLAN.md`](../DEMO-PLAN.md) D0–D4。
|
||||||
|
> 本文件是 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 的 D0–D4 切)
|
||||||
|
|
||||||
|
### 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-dpi:resize 事件里 `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 抢 layer(PLAN §03)。
|
||||||
|
- **成了** → M1 之后统一切 sokol_app(为移动端生命周期铺路)。**没成** → demo 全程用 T0a 的 SDL2/entry,sokol_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。 |
|
||||||
|
| 坐标系 basis:warrior 躺着 / 镜像 / 巨大 | **已解**:`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/R(T2)
|
||||||
|
- [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 mesh(Object16 / 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/9166(0 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(半透明排序) | cutout(alpha-test)已覆盖头发/飘带主要场景;真半透明要 OIT 或按深度排序,收益低 |
|
||||||
|
| 顶点色 | 语料里角色网格全是 PNT332(无 color 分量) |
|
||||||
|
| `.msm` 装配(换发型/换肤) | `formats/msm.cpp` 已能解析,装配是模型组合子系统,非渲染画质 |
|
||||||
|
| 法线贴图 / 切线帧 | PNT332 无切线;Metin2 资产也没有法线贴图 |
|
||||||
|
| 阴影 / IBL / FXAA / LOD 选择 | 已有 MSAA x4;阴影/IBL 是独立子系统,env map 资产缺失;FXAA 在 MSAA 之上边际收益小 |
|
||||||
|
| 特效 / 粒子 / 地形 / 水 / SpeedTree | 独立子系统,POC(读+渲+动 `.gr2` 骨骼模型)范围外 |
|
||||||
@@ -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×3(scaleshear 时要用逆转置)。
|
||||||
|
- `GrannyMeshIsRigid` 为真的网格不蒙皮,只按挂载骨骼刚体变换 —— 分支照搬。
|
||||||
|
- **完成判据**:bind pose(identity 动画)下 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 0–3(`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` 按骨骼名 retarget(181 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 3x3(scaleshear 严格应逆转置,M2 先近似,记 M4 收紧)|
|
||||||
|
| ε 拍脑袋 | 待 oracle T8 的 noise_floor × 10 |
|
||||||
|
| ~~数值门禁未跑~~ | **已跑**:macOS + Wine + MinGW 交叉编译的 `oracle.exe`(Granny 2.9.12)vs 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_01;dance_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 0–3 共享骨架:`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` 播放/暂停。 |
|
||||||
@@ -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 APK(NDK / Gradle 或纯 CMake + `native_app_glue`) | `platform/android/` |
|
||||||
|
| 三端 CI(macOS 原生 + 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 loss(PLAN §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 11(A13)+ Pixel 6(Mali-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` 源、同逻辑)。
|
||||||
|
- CI:iOS 模拟器 + Android 模拟器构建并启动,断言"到达首帧 + 连续 N 帧无 bgfx / Metal / GLES 验证层报错"(开 `BGFX_DEBUG_*`、Metal API validation、GLES `KHR_debug`)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 门禁(go / no-go)
|
||||||
|
|
||||||
|
- 三端渲染**差异分类通过**(不是"逐像素一致",见 PLAN §07 视觉层:SSIM ≥ 0.98 直接过;0.95–0.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 reset;M3 就做别拖 |
|
||||||
|
| 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_1,CPU 蒙皮 + 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 对比。 |
|
||||||
@@ -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` sampled;Adreno 老驱动测一下 |
|
||||||
|
| 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 反证清单 + 一页纸结论的"正式移植还缺什么"部分
|
||||||
Executable
+66
@@ -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
@@ -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"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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/FarDistance(cm,A1 = 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
|
||||||
@@ -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
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+1041
-85
File diff suppressed because it is too large
Load Diff
@@ -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> ¤t_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,
|
||||||
|
|||||||
@@ -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):HeightMapShape3D,129×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_conv(cm->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 先存进 meta(R1 不做室内裁剪)
|
||||||
|
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
|
||||||
@@ -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 §8(Metin2World 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_path(asset_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
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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" {
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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 Image,resize 到 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:185:u' = (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
|
||||||
@@ -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:逐图层按权重 mix,UV 按 TextureLayer.u_scale/offset 平铺
|
||||||
|
// - shadowmap.dds 作为 albedo 乘法项(有则)
|
||||||
|
// 光照交给 Godot 内置 DirectionalLight(W5 由 .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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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) 对应的区块本地 cm(texel = 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
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Vendored
+43
@@ -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)
|
||||||
+1
Submodule extension/third_party/libsodium-cmake added at 9b2848dfc1
+3
@@ -0,0 +1,3 @@
|
|||||||
|
Authors of the LZO data compression library:
|
||||||
|
|
||||||
|
Markus F.X.J. Oberhumer. Invented, designed and implemented LZO.
|
||||||
+339
@@ -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
@@ -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
@@ -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
@@ -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: */
|
||||||
+3268
File diff suppressed because it is too large
Load Diff
+6365
File diff suppressed because it is too large
Load Diff
+106
@@ -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: */
|
||||||
+1
Submodule extension/third_party/zstd added at 794ea1b0af
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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: Metin2↔Godot 坐标 / 单位 / 区块编号(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: .msenv(Group/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 alpha(RAW_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
Reference in New Issue
Block a user