Adapt Android mobile controls and native client UI

This commit is contained in:
shen
2026-09-28 19:39:13 -07:00
parent a1ae69aa7a
commit 3c52ced02e
180 changed files with 688 additions and 23782 deletions
+3 -25
View File
@@ -1,11 +1,7 @@
# Metin2 game assets (project/asset_root.gd). Committed to THIS self-hosted # Metin2 game assets are private. Do not publish or redistribute Ymir art.
# private repo for one-clone multi-device builds. Do not publish/redistribute
# — copyrighted Ymir art.
# build # build
/build/ /build/
# 渲染测试的落图输出(test/rendering 用例自建)
/project/build/
/build-ios/ /build-ios/
/build-android/ /build-android/
/build-classic/ /build-classic/
@@ -16,24 +12,6 @@
/build-native/ /build-native/
compile_commands.json compile_commands.json
# godot
/project/.godot/
/project/bin/*.dylib
/project/bin/*.dSYM/
/project/bin/*.so
/project/bin/*.dll
/project/bin/*.wasm
/project/bin/ios/
/project/bin/android/
# The embedded interpreter's stdlib, copied here from the build tree so the exporter packs it
# (extension/CMakeLists.txt, 批次 2P step 3c). Derived — never committed.
/project/python27.zip
/project/python27.zip.sha256
# 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
@@ -76,8 +54,8 @@ __pycache__/
/test/compare-shots/ /test/compare-shots/
/test/compare-report.json /test/compare-report.json
# Written by 40250 uitaskbar.py (SaveMouseButtonSettings) into the working directory at runtime. # Written by 40250 uitaskbar.py at runtime.
/project/mouse.cfg /mouse.cfg
# native Android client (./build-android-native.sh): SDL3 download, NDK builds, Gradle output # native Android client (./build-android-native.sh): SDL3 download, NDK builds, Gradle output
/build-android-native/ /build-android-native/
-3
View File
@@ -1,6 +1,3 @@
[submodule "extension/godot-cpp"]
path = extension/godot-cpp
url = https://github.com/godotengine/godot-cpp.git
[submodule "extension/third_party/zstd"] [submodule "extension/third_party/zstd"]
path = extension/third_party/zstd path = extension/third_party/zstd
url = https://github.com/facebook/zstd.git url = https://github.com/facebook/zstd.git
+17 -50
View File
@@ -1,62 +1,38 @@
# mtgodot-poc — top level
# 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(metin2_native CXX C)
include(CTest) 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)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
set(CMAKE_BUILD_TYPE Debug CACHE STRING "" FORCE) set(CMAKE_BUILD_TYPE Debug CACHE STRING "" FORCE)
endif() endif()
set(CMAKE_EXPORT_COMPILE_COMMANDS ON) option(MT_BUILD_TOOLS "Build libgr2 validation tools" OFF)
option(MT_BUILD_NET_TOOLS "Build the host-only classic protocol probe" OFF)
option(MT_BUILD_PORT "Build the 40250 port and its tests" ON)
option(MT_BUILD_NATIVE_RENDER "Build the SDL3 and Vulkan client" OFF)
option(MTGODOT_BUILD_EXTENSION "Build the Godot GDExtension (pulls in godot-cpp)" ON)
option(MTGODOT_BUILD_TOOLS "Build libgr2 validation tools (gr2dump/gr2fuzz/oracle_diff)" OFF)
option(MTGODOT_BUILD_NET_TOOLS "Build host-only protocol probes without Godot" OFF)
option(MTGODOT_BUILD_PORT "Build only port_logic (with MTGODOT_BUILD_EXTENSION=OFF), e.g. for Linux/Windows" OFF)
option(MT_BUILD_NATIVE_RENDER_PROTOTYPE "Build the standalone Vulkan draw-command prototype" OFF)
# --- shared, cross-platform core (no godot-cpp, no third-party) ---
add_subdirectory(libgr2) add_subdirectory(libgr2)
add_subdirectory(formats) add_subdirectory(formats)
add_subdirectory(extension/third_party)
add_subdirectory(extension/src/codepage)
if(MT_BUILD_PORT OR MT_BUILD_NATIVE_RENDER)
add_subdirectory(extension/src/port)
endif()
if(MTGODOT_BUILD_TOOLS) if(MT_BUILD_TOOLS)
add_subdirectory(tools) add_subdirectory(tools)
endif() endif()
# --- the Godot extension --- # The protocol probe remains useful on the archived server's FreeBSD host.
# Phase 1 was macOS-only; Phase 2 (BACKLOG F1/F2) adds iOS + Android. The three if(MT_BUILD_NET_TOOLS)
# 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)
endif()
# FreeBSD is the native build target of the archived 40250 server, but it is
# not a supported Godot extension target. Keep the live classic probe
# available as a small host-only build so a FreeBSD jail/VM can compile and run
# it without pulling in godot-cpp or the Darwin/iOS/Android extension.
if(MTGODOT_BUILD_NET_TOOLS AND NOT MTGODOT_BUILD_EXTENSION)
add_subdirectory(extension/third_party)
add_library(mtnet_host STATIC add_library(mtnet_host STATIC
extension/src/net/text_codec.cpp
extension/src/net/secure_cipher.cpp extension/src/net/secure_cipher.cpp
extension/src/net/net_stream.cpp extension/src/net/net_stream.cpp
extension/src/net/entity_store.cpp extension/src/net/entity_store.cpp
@@ -67,22 +43,13 @@ if(MTGODOT_BUILD_NET_TOOLS AND NOT MTGODOT_BUILD_EXTENSION)
extension/src/net/classic/classic_cipher.cpp extension/src/net/classic/classic_cipher.cpp
) )
target_include_directories(mtnet_host PUBLIC extension/src/net) target_include_directories(mtnet_host PUBLIC extension/src/net)
target_link_libraries(mtnet_host PUBLIC mt3p::sodium mt3p::minilzo mt3p::cryptopp) target_link_libraries(mtnet_host PUBLIC mt3p::sodium mt3p::minilzo mt3p::cryptopp mt_codepage)
target_compile_features(mtnet_host PUBLIC cxx_std_20) target_compile_features(mtnet_host PUBLIC cxx_std_20)
add_executable(net_classic_e2e_host extension/tools/net_classic_e2e.cpp) add_executable(net_classic_e2e_host extension/tools/net_classic_e2e.cpp)
target_link_libraries(net_classic_e2e_host PRIVATE mtnet_host) target_link_libraries(net_classic_e2e_host PRIVATE mtnet_host)
endif() endif()
# port_logic has no Godot dependency. On targets without an extension build (Linux, Windows) it is if(MT_BUILD_NATIVE_RENDER)
# built on its own so the 2A gate (header self-containment, static_asserts) runs per platform.
if(MTGODOT_BUILD_PORT AND NOT MTGODOT_BUILD_EXTENSION)
if(NOT TARGET mt3p::cryptopp)
add_subdirectory(extension/third_party)
endif()
add_subdirectory(extension/src/port)
endif()
if(MT_BUILD_NATIVE_RENDER_PROTOTYPE)
add_subdirectory(native_render) add_subdirectory(native_render)
endif() endif()
+22 -179
View File
@@ -1,193 +1,36 @@
# mtgodot-poc # Metin2 原生客户端
「**Godot 4 + 自研资源 loader**」跨平台方案的渲染 Demo。 40250 客户端的 macOS / Android 移植。SDL3 负责窗口与输入,Vulkan 负责 3D 和 UI 渲染;macOS 上通过 MoltenVK 使用 Metal。40250 的 C++ 逻辑、Python 2.7 脚本和 EPK 资源由 `extension/src/port` 与 `extension/src/platform` 提供。运行时不依赖 Godot。
用一个 C++ GDExtension 把 Metin2 的 `.gr2` 骨骼资源渲染进 Godot,用 Godot 内置渲染器。
> **2026-08-29:并入 `xrender-poc`。** 原先与本仓库并行的 bgfx 自研引擎方案(`xrender-poc`) ## macOS
> 经中期评审([`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)。
自用项目(内部研究,不对外发布),分两阶段推进: 需要 CMake、SDL3、Vulkan loader、MoltenVK、glslc,以及仓库中的第三方子模块:
| 阶段 | 平台 | 里程碑 | 产出 | ```sh
|---|---|---|---| git submodule update --init --recursive
| **Phase 1**(当前) | macOS | M0' · M1 · M2 · M2.5 | 中期评审 | cmake -S . -B build-release -DCMAKE_BUILD_TYPE=Release \
| Phase 2 | Android(一加 13 / Vulkan)· iOS(iPhone 16 / Metal) | M3 | 三设备各一次 bring-up + 性能留档 | -DMT_BUILD_NATIVE_RENDER=ON -DCMAKE_PREFIX_PATH=/opt/homebrew
cmake --build build-release --target mt_native_render -j8
自用、不对外发布,**无正式 go/no-go 门禁**;三台目标设备都是现代硬件,**全程不涉及 GLES3 / Compatibility 渲染器**,不做中低端 / 老机器。 ./build-release/native_render/mt_native_render \
--live-client "/path/to/40250/Client" --login-screen --width 1024 --height 768
完整计划见 [`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)。
当前进入游戏、模型/发型、贴地与技能栏问题的分阶段修复步骤,以及结合两个 Godot 项目的
对照验证和 macOS 包验收要求,见 [`docs/RENDERING-REPAIR-PLAN.md`](docs/RENDERING-REPAIR-PLAN.md)
(2026-09-08,实施计划;不表示其中工作已完成)。
---
## 现状
- [x] **M0'** — 脚手架。GDExtension 加载 + `Metin2Model` 注册 + `libgr2` 链接可调用
+ Godot 4.7 工程经 Metal Forward+ 出画。T0.4 实际 `export` 待装 4.7.1 导出模板。
- [x] **M1** — 静态渲染。`gr2` → `Skeleton3D` + `ArrayMesh` + `Skin` + 材质。
`warrior_cheongrin`(75 骨 / 5 表面 / 3324 顶点)正立、朝 +Z、贴图正确
(`test/golden/m1-bindpose.png`)。多骨架:`warrior_lord`(v6,74)、`assassin`(v6,95,17 表面)、
`shaman_lord`(**v7**,93) 均正确加载渲染。
- [x] **M2** — 骨骼动画。`Metin2AnimPlayer` `_process` 里 `gr2::sample_pose`(跨文件)
→ **默认 CPU 线性混合蒙皮**(把 libgr2 的 `Σ w·invWorld·world` 直接作用到顶点,每帧重建 mesh)。
`get_bone_global_pose` vs `conv(world)` 全骨 ≤2.6e-5;`selfcheck` 0 NaN;`dance_1` 等所有动作
头/护甲/躯干正确,与 xrender-poc bgfx demo 一致。
`MTGODOT_GPUSKIN=1` 走**自写蒙皮顶点着色器**(`m2_material` `SRC_SKIN`:逐骨完整 4×3 矩阵
存 RGBAF 纹理,绕过 `Skeleton3D` —— Godot 内置 GPU 蒙皮会正交化、丢 Granny 烘在 rig 骨上的
shear,warrior 7 根 / **sura 16 根**,见 `test/shear-bones-survey.md`)。人群场景用它。
**`.msa`**:`anim_path` 收 `.msa` → motion `.gr2` + `Accumulation` + `MotionEventData`
(`get_events()` / `motion_event` 信号,带循环回绕)+ `LoopData` 元数据;播放器 `loop=false`
可播一次并发 `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)。结论:桥接层工程量小、
风险基本出清,**建议进入 Phase 2**。`compare.py` 已有进程/截图/结构硬门禁,像素差仍为
advisory;收尾重点是前台/真机性能重测与桥接层全语料 draw 冒烟(默认已是 CPU LBS)。
50 角色压力采样:`test/godot-macos-stress.json`(后台窗口 30fps 节流,仅构建成本
~165ms/角色 和 100 角色破顶到 52ms 是可信信号)。
---
## 环境
| 组件 | 版本 / 位置 | 说明 |
|---|---|---|
| Godot | **4.7.1**(`/opt/homebrew/bin/godot`,Homebrew cask) | 编辑器 + headless |
| godot-cpp | submodule `extension/godot-cpp` @ `101ae38034304346a46ea9ea84ae156d3e860496` | 精确 gitlink 锁定(`.gitmodules` 不跟踪移动分支);bundled `extension_api-4-7.json` |
| Xcode | 26.4.1 | macOS 构建 |
| CMake | 已装;**不需要 SCons** | godot-cpp 走 CMake 路径 |
| libgr2 / formats / oracle / tools | vendored 进本仓库 | 见下「vendored 库」 |
**导出模板**(M0' T0.4 / 真正 export 才需要):编辑器内 `Editor → Manage Export Templates → Download`,或 `godot --headless --install-export-templates`。~600MB,版本须与编辑器一致(4.7.1)。
---
## 构建
```bash
git submodule update --init --recursive # 拉 godot-cpp(首次)
./build.sh # Debug;产物 → project/bin/libmtgodot.macos.template_debug.dylib
./build.sh Release # template_release
``` ```
首次会编译 godot-cpp(几分钟)。扩展构建自动带上 vendored `libgr2` + `formats`。 详见 [native_render/README.md](native_render/README.md)。
只想构建 `libgr2` / `formats` / 校验工具(不碰 godot-cpp): ## Android
```bash ```sh
cmake -B build-tools -DMTGODOT_BUILD_EXTENSION=OFF -DMTGODOT_BUILD_TOOLS=ON ./build-android-native.sh Release --install
cmake --build build-tools -j8 ./push-android-client.sh /path/to/40250/Client
./build-tools/tools/gr2fuzz "$PWD/assets" # 全量解析冒烟 adb shell am start -n org.metin2port.client/.MainActivity
./build-tools/tools/gr2dump <file.gr2>
``` ```
## 运行 APK 由 SDLActivity、`libSDL3.so` 和 Vulkan 原生 `libmain.so` 组成。40250 `Client` 资源单独复制到应用数据目录;`--live-server HOST:AUTH_PORT:GAME_PORT --login-screen` 可连接服务器。构建与启动细节见 [native_render/README.md](native_render/README.md)。
```bash ## 测试
# 完整客户端:登录 → 选人 → 进游戏(用仓库内 assets/)
./run-client.command # 或双击
# = MT_ASSETS=$PWD/assets godot --path project res://client_main.tscn
# 打包 .app(需先装 Godot 4.7.1 macOS 导出模板) ```sh
./build-macos-client.sh [debug|release] # -> build/export/mtgodot-poc.app ./script/port_gate.sh macos
# 编辑器打开
godot -e --path project
# 模型查看器 harness(旧 main_scene)
godot --path project res://main.tscn
# headless 冒烟测试(验证 GDExtension 加载 + 类注册)
godot --headless --path project --quit-after 3
# 跑一个真实 gr2 过 libgr2(可选)
MTGODOT_PROBE_GR2="$PWD/assets/PC/ymir work/pc/warrior/warrior_cheongrin.gr2" \
godot --headless --path project --quit-after 3
``` ```
控制台出现 `[mtgodot] Metin2Model registered OK — libgr2 linked; ...` = M0' 的 T0.1/T0.2 通过。 `port_gate.sh` 构建并运行 40250 移植层的 C++ 测试。原生渲染验收入口是 `script/native_mac_acceptance.mjs`。历史迁移记录保留在 `docs/PORT-PLAN.md`;其中旧 Godot 路径已经退役。
窗口内:拖拽 = 轨道,滚轮 = 缩放,`F2` = 截图(→ `user://`)。
---
## vendored 库(并入自 xrender-poc,2026-08-29)
| 目录 | 内容 | 谁用 |
|---|---|---|
| `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 输出交叉核对 |
CMake:顶层 `add_subdirectory(libgr2)` + `add_subdirectory(formats)`(跨平台,不碰 godot-cpp);
`extension` 只 `target_link_libraries(... xrender::libgr2)`。`tools` 由
`-DMTGODOT_BUILD_TOOLS=ON` 开启。
> `libgr2/README.md` 里的 **oodle1.c clean-room 提醒**依然适用:`src/oodle1.c` 是泄露 SDK 端口,
> 仅限内部研究 / 非发布 / 非商用;对外发布前必须 clean-room 重写。
---
## 目录
```
mtgodot-poc/
CMakeLists.txt 顶层:add_subdirectory(libgr2/formats/[tools]/[extension])
build.sh 便捷构建(扩展)
libgr2/ ← vendored:gr2 读取器(xrender::libgr2)
formats/ ← vendored:msa/msm/textscript
oracle/ ← vendored:Granny 真值工具(exe/dll 不入库)
tools/ ← vendored:gr2dump / gr2fuzz / oracle_diff
extension/
CMakeLists.txt mtgodot SHARED + godot-cpp(consume xrender::libgr2)
godot-cpp/ submodule @ 101ae38034304346a46ea9ea84ae156d3e860496
src/
register_types.{h,cpp} GDExtension 入口
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 工程
client_main.tscn/.gd 完整客户端入口(main_scene)→ AppFlow 登录串场
app_flow.gd LOGIN → SELECT → GAME 状态机
main.tscn / main.gd 旧模型查看器 harness
asset_root.gd AssetRoot:资源目录解析(MT_ASSETS / res://../assets / .app 内外)
docs/
GODOT-POC-PLAN.md 开发计划(M0'–M4 + 风险 + 验收)
MIDREVIEW.md Phase 1 中期评审
BACKLOG.md 离可用客户端的分层未完成清单
reference/ ← xrender-poc 的格式逆向笔记(archived)
test/
golden/ Godot 对拍截图
bgfx-reference/ bgfx demo 参考截图 + 数值基线
*.json noise_floor / m2-numeric / stress
```
## 授权
`libgr2` 全自研,不含 Granny SDK 代码 —— 例外:`libgr2/src/oodle1.c` 是泄露 SDK 的解码路径端口
(见 `libgr2/README.md`,仅限内部研究 / 非发布,对外发布前必须 clean-room 重写)。
本仓库内部研究用途,不对外公开。
@@ -1,6 +1,13 @@
package org.metin2port.client; package org.metin2port.client;
import android.content.Intent; import android.content.Intent;
import android.content.pm.ActivityInfo;
import android.os.Build;
import android.os.Bundle;
import android.util.Log;
import android.view.View;
import android.view.WindowInsets;
import android.view.WindowInsetsController;
import org.libsdl.app.SDLActivity; import org.libsdl.app.SDLActivity;
@@ -11,6 +18,47 @@ import org.libsdl.app.SDLActivity;
* Without arguments the client runs against the in-process fake server once Client/ is in files/. * Without arguments the client runs against the in-process fake server once Client/ is in files/.
*/ */
public class MainActivity extends SDLActivity { public class MainActivity extends SDLActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE);
applyImmersiveMode();
}
@Override
protected void onResume() {
super.onResume();
applyImmersiveMode();
}
@Override
public void onWindowFocusChanged(boolean hasFocus) {
super.onWindowFocusChanged(hasFocus);
if (hasFocus)
applyImmersiveMode();
}
private void applyImmersiveMode() {
final View decor = getWindow().getDecorView();
decor.setSystemUiVisibility(
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY |
View.SYSTEM_UI_FLAG_FULLSCREEN |
View.SYSTEM_UI_FLAG_HIDE_NAVIGATION |
View.SYSTEM_UI_FLAG_LAYOUT_STABLE |
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN |
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
getWindow().setDecorFitsSystemWindows(false);
final WindowInsetsController controller = getWindow().getInsetsController();
if (controller != null) {
controller.hide(WindowInsets.Type.systemBars());
controller.setSystemBarsBehavior(
WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE);
}
}
}
@Override @Override
protected String[] getLibraries() { protected String[] getLibraries() {
return new String[] { "SDL3", "main" }; return new String[] { "SDL3", "main" };
@@ -20,8 +68,15 @@ public class MainActivity extends SDLActivity {
protected String[] getArguments() { protected String[] getArguments() {
Intent intent = getIntent(); Intent intent = getIntent();
String args = intent != null ? intent.getStringExtra("args") : null; String args = intent != null ? intent.getStringExtra("args") : null;
Log.i("Metin2Args", "native args: " + args);
if (args == null || args.trim().isEmpty()) if (args == null || args.trim().isEmpty())
return new String[0]; return new String[0];
return args.trim().split("\\s+"); return args.trim().split("\\s+");
} }
@Override
public void setOrientationBis(int w, int h, boolean resizable, String hint) {
// Enforce landscape-only on mobile devices (sensorLandscape allows flipping 180 deg but never portrait).
setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE);
}
} }
+2 -2
View File
@@ -57,8 +57,8 @@ fi
# --- libmain.so (native_render + port_platform + embedded Python) and its assets --- # --- libmain.so (native_render + port_platform + embedded Python) and its assets ---
cmake -S . -B "$OUT/app-build" -G "Unix Makefiles" -Wno-deprecated \ cmake -S . -B "$OUT/app-build" -G "Unix Makefiles" -Wno-deprecated \
-DCMAKE_TOOLCHAIN_FILE="$TOOLCHAIN" -DANDROID_ABI=arm64-v8a -DANDROID_PLATFORM="android-$API" \ -DCMAKE_TOOLCHAIN_FILE="$TOOLCHAIN" -DANDROID_ABI=arm64-v8a -DANDROID_PLATFORM="android-$API" \
-DCMAKE_BUILD_TYPE="$CONFIG" -DMTGODOT_BUILD_TOOLS=OFF -DBUILD_TESTING=OFF \ -DCMAKE_BUILD_TYPE="$CONFIG" -DMT_BUILD_TOOLS=OFF -DBUILD_TESTING=OFF \
-DMT_BUILD_NATIVE_RENDER_PROTOTYPE=ON -DSDL3_DIR="$OUT/sdl3/lib/cmake/SDL3" \ -DMT_BUILD_NATIVE_RENDER=ON -DSDL3_DIR="$OUT/sdl3/lib/cmake/SDL3" \
-DCMAKE_FIND_ROOT_PATH="$OUT/sdl3" -DGLSLC="$GLSLC" >/dev/null -DCMAKE_FIND_ROOT_PATH="$OUT/sdl3" -DGLSLC="$GLSLC" >/dev/null
cmake --build "$OUT/app-build" --target main mt_native_android_assets -j "$JOBS" cmake --build "$OUT/app-build" --target main mt_native_android_assets -j "$JOBS"
-50
View File
@@ -1,50 +0,0 @@
#!/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:-$(getconf _NPROCESSORS_ONLN 2>/dev/null || echo 4)}"
# --- 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
-50
View File
@@ -1,50 +0,0 @@
#!/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"
-116
View File
@@ -1,116 +0,0 @@
#!/usr/bin/env bash
# 打包 macOS 完整客户端(登录 → 选人 → 进游戏)。
#
# ./build-macos-client.sh [debug|release]
#
# 产物:build/export/mtgodot-poc.app(40250 Client/pack 和 BGM 自包含)。
# 依赖: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)"
SOURCE_CLIENT="${MT_40250_CLIENT:-$REPO/Client}"
if [ ! -f "$SOURCE_CLIENT/pack/Index" ] && [ -f "$REPO/../40250/Server Client TMP4/Client/pack/Index" ]; then
SOURCE_CLIENT="$REPO/../40250/Server Client TMP4/Client"
fi
CFG="${1:-debug}"
OUT="${MT_MAC_OUTPUT_DIR:-$REPO/build/export}"
APP="$OUT/mtgodot-poc.app"
GODOT="${GODOT:-godot}"
MAC_ARCHES="${MT_MAC_ARCHES:-universal}"
ENGINE_OVERRIDE="${MT_MAC_ENGINE:-}"
test -f "$SOURCE_CLIENT/pack/Index" || {
echo "错误:缺少 40250 Client/pack/Index:$SOURCE_CLIENT" >&2
exit 1
}
if [ -n "$ENGINE_OVERRIDE" ]; then
test -x "$ENGINE_OVERRIDE"
ENGINE_INFO="$(lipo -info "$ENGINE_OVERRIDE")"
case "$MAC_ARCHES:$ENGINE_INFO" in
universal:*x86_64*arm64*|universal:*arm64*x86_64*|arm64:*arm64*|x86_64:*x86_64*) ;;
*) echo "错误:自定义引擎架构不匹配:$ENGINE_INFO" >&2; exit 1 ;;
esac
echo "自定义引擎:$ENGINE_OVERRIDE"
"$ENGINE_OVERRIDE" --version
fi
BUILD_TYPE="Debug"
if [ "$CFG" = release ]; then
BUILD_TYPE="Release"
fi
echo "== 1. GDExtension .dylib =="
if [ "$MAC_ARCHES" = universal ]; then
CMAKE_ARCHES="arm64;x86_64"
else
CMAKE_ARCHES="$MAC_ARCHES"
fi
cmake -S . -B build -DCMAKE_BUILD_TYPE="$BUILD_TYPE" -DCMAKE_OSX_ARCHITECTURES="$CMAKE_ARCHES"
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
if [ "$MAC_ARCHES" = universal ]; then
LIPO_INFO="$(lipo -info "$DYLIB")"
case "$LIPO_INFO" in
*x86_64*arm64*|*arm64*x86_64*) ;;
*) echo "错误:GDExtension 不是 universal:$LIPO_INFO" >&2; exit 1 ;;
esac
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. 40250 资源(自包含)=="
BUNDLE_CLIENT="$APP/Contents/Resources/40250/Client"
mkdir -p "$BUNDLE_CLIENT"
ditto "$SOURCE_CLIENT/pack" "$BUNDLE_CLIENT/pack"
if [ -d "$SOURCE_CLIENT/BGM" ]; then ditto "$SOURCE_CLIENT/BGM" "$BUNDLE_CLIENT/BGM"; fi
if [ -f "$SOURCE_CLIENT/locale.cfg" ]; then cp "$SOURCE_CLIENT/locale.cfg" "$BUNDLE_CLIENT/locale.cfg"; fi
if [ -d "$SOURCE_CLIENT/sound" ]; then
ditto "$SOURCE_CLIENT/sound" "$BUNDLE_CLIENT/sound"
elif [ -d "$REPO/assets/Sound/sound" ]; then
ditto "$REPO/assets/Sound/sound" "$BUNDLE_CLIENT/sound"
fi
test -s "$BUNDLE_CLIENT/pack/Index"
# Record the exact source revision inside the signed bundle. A Beta release is built only
# after its changes are committed, so the identity remains useful when the checkout moves on.
python3 - "$REPO" "$APP/Contents/Resources/beta-build.json" <<'PY'
import json, pathlib, subprocess, sys
repo, output = sys.argv[1:]
commit = subprocess.check_output(['git', '-C', repo, 'rev-parse', 'HEAD'], text=True).strip()
dirty = bool(subprocess.check_output(['git', '-C', repo, 'status', '--porcelain', '--untracked-files=normal'], text=True).strip())
pathlib.Path(output).write_text(json.dumps({'source_commit': commit, 'source_dirty': dirty}, indent=2) + '\n')
PY
echo "== 4. 签名(默认 ad-hoc;CODESIGN_IDENTITY 可切正式证书)=="
if [ -n "$ENGINE_OVERRIDE" ]; then
cp "$ENGINE_OVERRIDE" "$APP/Contents/MacOS/mtgodot-poc"
fi
CODESIGN_IDENTITY="${CODESIGN_IDENTITY:--}"
ENTITLEMENTS="$REPO/macos.entitlements"
test -f "$ENTITLEMENTS"
# The exported Godot executable loads the GDExtension from Contents/Frameworks.
# Because this package uses hardened runtime, the main executable must carry
# disable-library-validation or macOS rejects the ad-hoc/formally signed dylib
# at runtime even when the bundle passes a static codesign verification.
find "$APP/Contents/Frameworks" -type f \( -name '*.dylib' -o -name '*.so' \) -exec \
codesign --force --options runtime --timestamp=none --sign "$CODESIGN_IDENTITY" {} \;
codesign --force --options runtime --timestamp=none --entitlements "$ENTITLEMENTS" \
--sign "$CODESIGN_IDENTITY" "$APP/Contents/MacOS/mtgodot-poc"
codesign --force --options runtime --timestamp=none --entitlements "$ENTITLEMENTS" \
--sign "$CODESIGN_IDENTITY" "$APP"
xattr -dr com.apple.quarantine "$APP" 2>/dev/null || true
echo
echo "OK -> $APP"
echo "跑: open '$APP'"
echo "开发覆盖资源:MT_ASSETS='$REPO/assets' '$APP/Contents/MacOS/mtgodot-poc'"
-27
View File
@@ -1,27 +0,0 @@
#!/usr/bin/env bash
# Build the mtgodot GDExtension into project/bin/. macOS only (Phase 1).
set -euo pipefail
cd "$(dirname "$0")"
CONFIG="${1:-Debug}" # Debug | Release
JOBS="${JOBS:-$(sysctl -n hw.ncpu)}"
MAC_ARCHES="${MT_MAC_ARCHES:-$(uname -m)}" # arm64, x86_64, or universal
# godot-cpp + the vendored deps (zstd, libsodium-cmake and its nested libsodium)
# are submodules — recurse. miniLZO is vendored in-tree, nothing to fetch.
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
if [ "$MAC_ARCHES" = universal ]; then
MAC_ARCHES="arm64;x86_64"
fi
cmake -S . -B build -DCMAKE_BUILD_TYPE="$CONFIG" -DCMAKE_OSX_ARCHITECTURES="$MAC_ARCHES"
cmake --build build --config "$CONFIG" -j "$JOBS"
echo
echo "Built libs in project/bin/:"
ls -la project/bin/*.dylib 2>/dev/null || echo " (none — check build output above)"
+15 -384
View File
@@ -1,393 +1,24 @@
<!-- generated-by: gsd-doc-writer --> # Android 原生客户端测试
# Android 真机测试指南
本文说明如何在 Android 真机上安装本客户端(APK 产物名仍是 `mtgodot-poc.apk`,包名 `org.internal.mtgodotpoc`)、生成并导入资源包、验证资源挂载,以及进行 40250 客户端测试。 当前 APK 由 `build-android-native.sh` 构建,使用 SDL3 + Vulkan;应用包名 `org.metin2port.client`,ABI 为 `arm64-v8a`,最低 API 24。
## 1. 当前测试范围与已知前置条件 ## 安装和资源
当前 Android 导出目标是 `arm64-v8a`,APK 包名为 `org.internal.mtgodotpoc`,最低 Android SDK 为 24。APK 只包含代码和 Godot 工程资源;Metin2 的 `assets/` 与 `bgm/` 不打进 APK,测试时必须单独导入 `assets.zip`。 ```sh
./build-android-native.sh Release --install
当前仓库的资源部署存在一个需要特别注意的限制: ./push-android-client.sh /path/to/40250/Client
adb shell am start -n org.metin2port.client/.MainActivity
- `export-android.sh --install` 会把资源推到 `/sdcard/Android/data/org.internal.mtgodotpoc/files/assets.zip`。
- `project/asset_pack.gd` 的候选顺序是 `MT_ASSETS_ZIP` 环境变量 → `user://assets.zip` → `OS.get_user_data_dir()/assets.zip` → 可执行文件目录 → `res://../assets.zip`(见 `_find_local_zip()`);上述外部目录不在候选路径里。
- 因此,Debug 真机测试应把外部目录中的压缩包再复制到应用私有 `files/` 目录,确保客户端的 `user://assets.zip` 能命中。
另外,40250 classic 网络栈当前由进程环境变量 `MT_PROTOCOL=classic` 选择。Android 通过 Activity 启动 APK 时不能直接继承 Mac 终端的环境变量;如果 APK 尚未增加 Android 侧的协议配置入口或将 classic 设为移动端默认值,则只能完成 APK、资源和 UI 验证,不能据此宣称已经完成真实 40250 登录验收。
公会功能按当前项目范围暂缓;龙魂玩法不属于目标 40250 服务器功能,不纳入 Android 验收。
## 2. 主机环境
以下版本来自当前 Android Gradle 配置和构建脚本:
| 项目 | 要求 |
|---|---|
| 主机 | macOS,Apple Silicon 构建脚本已按当前机器验证 |
| Godot | `4.7.1.stable`,并安装对应 Android 导出模板 |
| Android SDK | compile/target SDK `36`,Build Tools `36.1.0` |
| Android NDK | `29.0.14206865` |
| Java | Gradle 配置最低 Java 17;当前脚本默认使用 OpenJDK 21 |
| Android 手机 | `arm64-v8a`,建议使用 Vulkan 设备 |
| 磁盘 | 至少预留 APK、资源包和临时构建空间;当前 `assets.zip` 约 2.1–2.2 GB |
进入仓库并设置环境变量。路径按本机安装位置调整:
```bash
cd /Users/shenlei/Work/mt/metin2-client
export ANDROID_SDK_ROOT="$HOME/Library/Android/sdk"
export ANDROID_HOME="$ANDROID_SDK_ROOT"
export ANDROID_NDK_ROOT="$ANDROID_SDK_ROOT/ndk/29.0.14206865"
export JAVA_HOME="/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home"
export GODOT="/opt/homebrew/bin/godot"
export ADB="$ANDROID_SDK_ROOT/platform-tools/adb"
``` ```
先确认工具存在: `push-android-client.sh` 将 40250 `Client` 目录复制到应用私有目录。APK 本身包含 `libmain.so`、`libSDL3.so`、着色器和 Python 2.7 标准库,不包含约 1.4 GB 的 40250 客户端资源。使用中文资源时运行 `./push-android-client.sh --locale zh`。
```bash ## 连服务器
"$GODOT" --version
java -version ```sh
"$ADB" version adb shell am start -n org.metin2port.client/.MainActivity \
test -f "$ANDROID_NDK_ROOT/build/cmake/android.toolchain.cmake" --es args "--live-server HOST:AUTH_PORT:GAME_PORT --login-screen"
``` ```
如果 Android SDK/NDK 尚未安装,可在 Android Studio 的 SDK Manager 中安装,或使用 `sdkmanager` 安装与上表一致的版本。不要只安装一个较旧 NDK 后直接构建;Gradle 模板要求的 NDK 版本以 `project/android/build/config.gradle` 为准。 默认不传参数时连接进程内假服。测试真服前确认模拟器/设备能访问相应主机与端口。用 `adb logcat` 查看崩溃和启动错误。`adb devices -l` 没有设备时,先连接模拟器或开启真机 USB 调试。
Godot 还需要安装与 `4.7.1` 完全匹配的 Android 导出模板。可在 Godot 的“编辑器设置 → 导出 → 导出模板”中安装。 更详细的构建依赖与命令见 [native_render/README.md](../native_render/README.md)。
首次导出前生成 Debug keystore:
```bash
./gen-debug-keystore.sh
```
## 3. 连接 Android 真机
在手机上完成以下设置:
1. 设置 → 关于手机 → 连续点击版本号,开启开发者选项。
2. 开发者选项中开启“USB 调试”。
3. USB 连接模式选择“文件传输/MTP”。
4. 首次连接时,在手机上确认“允许 USB 调试”。
在 Mac 上检查设备:
```bash
"$ADB" kill-server
"$ADB" start-server
"$ADB" devices -l
```
期望看到:
```text
<设备序列号> device
```
状态为 `unauthorized` 时,解锁手机并接受 RSA 授权;没有设备时,优先检查 USB 线、USB 模式、开发者选项和手机是否允许该电脑调试。
## 4. 生成资源包
资源包必须从与 APK 同一份工作树生成。执行:
```bash
./pack-assets.sh
```
该脚本会:
1. 执行 `bake_asset_index.gd`,生成 `assets/asset_index.txt`。
2. 将 `assets/` 和 `bgm/` 打成 `build/export/assets.zip`。
3. 使用 `zip -0` 原样存储,不压缩 `.dds`、`.gr2` 等大型资源。
4. 排除 `.git`、`.DS_Store` 和 `assets/.gdignore`。
检查资源包:
```bash
ls -lh build/export/assets.zip
unzip -l build/export/assets.zip | rg 'assets/asset_index\.txt|assets/|bgm/' | head -n 20
```
不要手工解压到 APK,也不要把完整 `assets/` 强行放进 `res://`。客户端启动时通过 `ProjectSettings.load_resource_pack()` 挂载 zip,挂载成功后资源以 `res://assets/...` 和 `res://bgm/...` 访问。
资源发生变化后必须重新执行 `./pack-assets.sh`。只重新导出 APK 不会更新已经生成的 `assets.zip`。
## 5. 构建 APK
建议先单独构建,不要在第一次测试时直接使用 `--install`:
```bash
./export-android.sh Debug
```
构建成功后应得到:
```text
build/export/mtgodot-poc.apk
```
检查 APK 和 native library:
```bash
ls -lh build/export/mtgodot-poc.apk
unzip -l build/export/mtgodot-poc.apk | rg 'lib/arm64-v8a/.*\.so'
```
应至少包含 `lib/arm64-v8a/` 下的 Godot Android 库和 `libmtgodot` 扩展库。
## 6. 安装 APK 并导入资源
### 6.1 安装 APK
```bash
"$ADB" install -r build/export/mtgodot-poc.apk
```
`-r` 会保留应用数据。若要完全清理测试数据,可选执行:
```bash
"$ADB" shell pm clear org.internal.mtgodotpoc
```
清理后必须重新导入资源包。
### 6.2 先推送到 Android 外部目录
```bash
PKG=org.internal.mtgodotpoc
EXT_DIR="/sdcard/Android/data/$PKG/files"
"$ADB" shell mkdir -p "$EXT_DIR"
"$ADB" push build/export/assets.zip "$EXT_DIR/assets.zip"
"$ADB" shell ls -lh "$EXT_DIR/assets.zip"
```
### 6.3 Debug 测试复制到 Godot `user://`
当前 `Debug` APK 可使用 `run-as` 把外部文件复制到应用私有 `files/` 目录:
```bash
"$ADB" shell run-as "$PKG" sh -c \
"cp '$EXT_DIR/assets.zip' files/assets.zip"
"$ADB" shell run-as "$PKG" ls -lh files/assets.zip
```
最后一条命令能看到约 2.1–2.2 GB 的文件,才继续启动客户端。这个步骤是当前代码路径下的关键步骤;只执行 `adb push` 到 `/sdcard/Android/data/.../files/`,不能替代它。
如果 `run-as` 报告应用不可调试,确认安装的是 `Debug` APK,而不是 Release APK。Release 流程需要先修改资源查找/部署实现,使客户端明确支持外部目录,不能假设 `run-as` 可用。
### 6.4 资源更新
客户端退出后,只更新资源包时执行:
```bash
./pack-assets.sh
"$ADB" push build/export/assets.zip "$EXT_DIR/assets.zip"
"$ADB" shell run-as "$PKG" sh -c \
"cp '$EXT_DIR/assets.zip' files/assets.zip"
```
资源挂载只在 `client_main.gd::_ready()` 中执行,因此更新后必须重启客户端,不能只切回前台:
```bash
"$ADB" shell am force-stop "$PKG"
```
## 7. 启动客户端并确认资源挂载
清空旧日志后启动:
```bash
"$ADB" logcat -c
"$ADB" shell am start -n \
org.internal.mtgodotpoc/com.godot.game.GodotApp
```
实时查看日志:
```bash
"$ADB" logcat -v time -s godot GodotError AndroidRuntime
```
资源挂载成功的关键日志是:
```text
[AssetPack] mounted .../assets.zip
```
以下日志表示资源没有被找到或挂载失败:
```text
[AssetPack] 找不到 assets.zip
[AssetPack] load_resource_pack 失败
[client] 资源目录不存在
```
启动验收至少包含:
- 登录界面正常出现,而不是黑屏或立即退出。
- 登录背景、控件和字体资源正常显示。
- 选人页能加载职业模型、背景和界面资源。
- 进入游戏后能看到地图、地形、建筑、树木或角色模型。
- 日志中没有持续出现资源文件缺失、`FileAccess` 打开失败或 native library 加载失败。
可保存截图作为测试证据:
```bash
mkdir -p build/export/android-evidence
"$ADB" exec-out screencap -p > build/export/android-evidence/launch.png
```
## 8. 40250 服务器连接配置
### 8.1 手机网络要求
手机必须能直接访问认证服/游戏服的 IP 和端口:
- 手机与局域网测试服连接到同一网络,或服务器开放公网访问。
- 客户端不能使用 `127.0.0.1` 或 `localhost` 指向 Mac;在手机上它们指向手机自身。
- 防火墙和 FreeBSD jail 的端口映射必须允许手机来源地址访问。
- 服务器端口以实际 `CONFIG` 为准,不要照抄示例端口。
### 8.2 `serverlist.txt`
客户端启动时会优先读取 `res://serverlist.txt`;没有该文件时使用 `project/net/serverinfo.gd` 中的内置默认值。真实 40250 测试应在导出前准备 `project/serverlist.txt`,格式为 TSV:
```text
# 名称\t认证地址\t认证端口\t游戏地址\t游戏端口\t频道列表\t端口步长\t会徽端口
龙魂40250\t<服务器IP>\t<auth-port>\t<服务器IP>\t<channel1-port>\t1,2,3,4\t1\t0
```
例如 40250 仓库脚本中的默认 channel1 端口与当前客户端内置默认值并不相同,必须以目标 FreeBSD 服务器的 `CONFIG` 和实际监听结果为准。
<!-- VERIFY: 真实 40250 服务器 IP、auth/game 端口、频道数量和防火墙策略必须由当前 FreeBSD 部署确认。 -->
### 8.3 classic 协议选择限制
当前 native 代码仅在进程环境变量满足以下条件时切换 40250 classic 后端:
```text
MT_PROTOCOL=classic
```
在 Mac 上运行 Godot 时可以通过环境变量设置;但 Android APK 是由 Activity 启动的,普通的 `adb shell am start` 不会把 Mac 的环境变量传入 APK。因此,真实 Android 40250 登录测试前必须确认以下任一项已经完成:
1. Android 版本把 classic 设置为默认网络后端;
2. 客户端增加可持久化的协议配置并在 Android 导出中设为 `classic`;或
3. Android 测试包提供协议选择入口。
不要把下面的命令当作可靠解决方案:
```bash
adb shell MT_PROTOCOL=classic am start ...
```
它设置的是 `am` 命令进程的环境,不等于给已启动的 Android Activity 设置环境变量。
## 9. 真机功能验收清单
以下项目按实际 40250 测试账号、角色和 NPC 条件逐项记录:
### 启动与网络
- [ ] APK 安装成功,包名为 `org.internal.mtgodotpoc`。
- [ ] `assets.zip` 已复制到应用私有 `files/`,大小与主机一致。
- [ ] 日志出现 `[AssetPack] mounted`。
- [ ] 登录界面资源完整显示。
- [ ] 服务器/频道检测能得到预期结果。
- [ ] 认证成功后进入选人页。
- [ ] 选择角色后进入游戏场景。
### 世界与基础操作
- [ ] 地图、地形、建筑、树木、角色和纹理正常加载。
- [ ] 点击地面移动,角色位置与服务器同步。
- [ ] 普通攻击、目标选择、技能和动作没有异常断线。
- [ ] 聊天发送与接收正常。
- [ ] 切换前后台后连接和场景状态符合预期;断线时能看到重连提示。
### 已接入的常用功能
- [ ] 背包打开、物品使用、丢弃、移动和快捷栏操作。
- [ ] NPC 对话、脚本选项、输入框和确认框。
- [ ] 普通商店查看、切换货架和购买。
- [ ] 私人商店物品选择、定价、开设和撤收。
- [ ] 交易发起、物品/金币添加、确认和取消。
- [ ] 仓库密码、存入、取出和物品移动。
- [ ] 商城物品列表和取出。
- [ ] 组队邀请、接受、离队、成员状态和治疗入口。
- [ ] ESC 系统菜单、音量、镜头、雾效和显示设置持久化。
### 明确不纳入本轮
- 公会相关功能:按当前范围暂缓。
- 龙魂:目标 40250 服务器没有该功能,不进行功能验收。
- 真实 FreeBSD + MySQL 全流程验收:需要服务器环境和测试账号,不能用本机离线测试结果替代。
## 10. 测试证据保存
建议每次测试保留 APK、资源包校验值、设备信息和日志:
```bash
mkdir -p build/export/android-evidence
shasum -a 256 build/export/mtgodot-poc.apk \
build/export/assets.zip \
> build/export/android-evidence/artifacts.sha256
"$ADB" shell getprop ro.product.model \
> build/export/android-evidence/device-model.txt
"$ADB" shell getprop ro.build.version.release \
> build/export/android-evidence/android-version.txt
"$ADB" logcat -d -v time -s godot GodotError AndroidRuntime \
> build/export/android-evidence/logcat.txt
```
测试记录至少填写:构建时间、Git commit、APK SHA-256、`assets.zip` SHA-256、手机型号、Android 版本、服务器地址/端口、账号、测试结果和失败日志。
## 11. 常见问题
| 现象 | 处理 |
|---|---|
| `adb devices` 没有设备 | 开启 USB 调试、接受 RSA、切换 MTP、检查数据线;必要时重新执行 `adb kill-server` / `adb start-server` |
| 状态是 `unauthorized` | 解锁手机并接受“允许 USB 调试”,然后重新执行 `adb devices` |
| APK 安装失败 | 检查设备是否为 arm64;卸载旧包后再安装;查看 `adb install` 的具体错误 |
| `run-as` 报不可调试 | 安装 `Debug` APK;Release APK 不能依赖 `run-as` 导入资源 |
| 日志找不到 `assets.zip` | 确认外部 push 成功,再执行 `run-as ... cp ... files/assets.zip`,然后重启应用 |
| `load_resource_pack 失败` | 检查压缩包完整性、磁盘空间、是否由 `./pack-assets.sh` 生成,以及 zip 内是否存在 `assets/asset_index.txt` |
| 登录连接超时 | 手机不能使用 `localhost`;检查服务器 IP、端口、Wi-Fi/VPN、防火墙和 FreeBSD jail 映射 |
| 登录协议不对或立即断线 | 确认 Android 包实际选择了 `classic`,仅在 Mac 终端设置 `MT_PROTOCOL` 不会自动传入 APK |
| 黑屏或 Vulkan 崩溃 | 保存完整 `logcat`;当前 Android 移动 Vulkan 的 shader/GPU-skin 真机验证仍需在目标设备上进行 |
| 更新资源后画面不变 | 资源包挂载发生在启动阶段,执行 `am force-stop` 后重新启动;必要时清理应用数据并重新导入 |
## 12. 推荐的最短测试流程
首次测试按以下顺序执行:
```bash
cd /Users/shenlei/Work/mt/metin2-client
./pack-assets.sh
./export-android.sh Debug
export ADB="${ANDROID_SDK_ROOT:-$HOME/Library/Android/sdk}/platform-tools/adb"
PKG=org.internal.mtgodotpoc
EXT_DIR="/sdcard/Android/data/$PKG/files"
"$ADB" install -r build/export/mtgodot-poc.apk
"$ADB" shell mkdir -p "$EXT_DIR"
"$ADB" push build/export/assets.zip "$EXT_DIR/assets.zip"
"$ADB" shell run-as "$PKG" sh -c \
"cp '$EXT_DIR/assets.zip' files/assets.zip"
"$ADB" shell run-as "$PKG" ls -lh files/assets.zip
"$ADB" logcat -c
"$ADB" shell am start -n "$PKG/com.godot.game.GodotApp"
"$ADB" logcat -v time -s godot GodotError AndroidRuntime
```
只有在看到 `[AssetPack] mounted`、登录界面资源正常、并确认 Android 包使用正确的 40250 classic 后端后,才进入真实服务器功能验收。
+8 -203
View File
@@ -1,207 +1,12 @@
# Platform builds (macOS / iOS / Android) # 平台构建
Phase 1 shipped the GDExtension for macOS only. As of 2026-08-30 the build system 当前客户端使用 SDL3 + Vulkan 原生渲染,目标平台是 macOS arm64 和 Android arm64。核心 40250 移植代码在 `extension/src/port` 与 `extension/src/platform`,与 UI 引擎无关。
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`.
**Scope (decided 2026-09-23, PORT-PLAN §1):** the shipping targets are **macOS | 平台 | 构建入口 | 产物 |
arm64** and **Android arm64**. iOS arm64 stays cross-compile-clean but is not an | --- | --- | --- |
acceptance target. **Linux and Windows are not done** — the mingw-w64 cross | macOS | `cmake -S . -B build-release -DCMAKE_BUILD_TYPE=Release -DMT_BUILD_NATIVE_RENDER=ON -DCMAKE_PREFIX_PATH=/opt/homebrew`,然后 `cmake --build build-release --target mt_native_render -j8` | `build-release/native_render/mt_native_render` |
build is kept only as a portability gate (it catches real include/layering bugs), | Android | `./build-android-native.sh Release` | `android-native/app/build/outputs/apk/debug/app-debug.apk` |
not as Windows support, and MSVC is not built at all.
## What each script does macOS 的 Vulkan loader 通过 MoltenVK 使用 Metal。Android APK 使用 SDLActivity 和 `libmain.so`。40250 `Client` 资源在 macOS 通过 `--live-client` 指定,在 Android 通过 `push-android-client.sh` 放进应用目录。
| Script | Target | Output | 原生客户端的详细配置与验收步骤见 [native_render/README.md](../native_render/README.md) 和 [ANDROID-TESTING.md](ANDROID-TESTING.md)。
|---------------------|---------------------------|---------------------------------------------------------|
| `./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.
+2
View File
@@ -1,5 +1,7 @@
# 40250 1:1 移植:路线与方案 # 40250 1:1 移植:路线与方案
> 历史迁移记录:2026-09-28 起,客户端使用 `native_render/` 的 SDL3 + Vulkan 原生路径。本文关于 Godot 工程、GDExtension、`project/`、Godot 导出和相应门禁的描述仅记录过去的迁移过程;这些代码已删除。当前构建方法见根目录 README 和 `native_render/README.md`。
本文是移植工作的总入口,换一台电脑继续开发时先读这份。更新日期:2026-09-22。 本文是移植工作的总入口,换一台电脑继续开发时先读这份。更新日期:2026-09-22。
- 方法细则:`.agents/skills/metin2-40250-parity-audit/SKILL.md`(每轮的做法、状态定义、工具) - 方法细则:`.agents/skills/metin2-40250-parity-audit/SKILL.md`(每轮的做法、状态定义、工具)
-79
View File
@@ -1,79 +0,0 @@
#!/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"
OUT="build/export/mtgodot-poc-release.apk"
;;
*)
MODE="--export-debug"
OUT="build/export/mtgodot-poc.apk"
;;
esac
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"
if [ "$CONFIG" != "Release" ]; then
echo ">> copying assets.zip into the Debug app user:// directory"
# Android 14/15 may deny run-as reading /sdcard even for the app's own
# external files directory. Stream through adb instead.
"$ADB" shell "run-as org.internal.mtgodotpoc sh -c 'cat > files/assets.zip'" \
< build/export/assets.zip
fi
else
echo ">> NOTE: no build/export/assets.zip — run ./pack-assets.sh; the world won't render without it"
fi
"$ADB" shell am start -W -n org.internal.mtgodotpoc/com.godot.game.GodotAppLauncher \
-a android.intent.action.MAIN -c android.intent.category.LAUNCHER
echo ">> logcat: $ADB logcat -s godot GodotError Godot"
fi
-242
View File
@@ -1,242 +0,0 @@
# 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 pinned at 101ae38034304346a46ea9ea84ae156d3e860496) ---
# The parent gitlink is the source-of-truth; .gitmodules intentionally has no
# 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)
add_subdirectory(godot-cpp)
# --- vendored native deps: libsodium / libzstd / miniLZO ---
# Built from source (pinned submodules + vendored miniLZO) so the same tree
# compiles on the Android NDK and iOS SDK, which have no Homebrew. Exposes the
# aliases mt3p::sodium / mt3p::zstd / mt3p::minilzo. See docs/THIRD-PARTY.md.
add_subdirectory(third_party)
add_subdirectory(src/codepage)
# --- mtnet: Metin2 net protocol core (no godot-cpp dep; libsodium only) ---
add_library(mtnet STATIC
src/net/text_codec.cpp
src/net/secure_cipher.cpp
src/net/net_stream.cpp
src/net/entity_store.cpp
src/net/mark_image.cpp
src/net/classic/classic_stream.cpp # MT_PROTOCOL=classic (40250)
src/net/classic/classic_parser.cpp
src/net/classic/classic_session.cpp
src/net/classic/classic_cipher.cpp
)
target_include_directories(mtnet PUBLIC src/net)
target_link_libraries(mtnet PUBLIC mt3p::sodium mt3p::minilzo mt3p::cryptopp mt_codepage)
target_compile_features(mtnet PUBLIC cxx_std_20)
# §1.5: non-EUROPE CG_CLIENT_VERSION timestamp, in C __TIMESTAMP__ form
# ("Www Mmm dd hh:mm:ss yyyy"); regenerated on each CMake configure.
string(TIMESTAMP MT_BUILD_TS "%a %b %d %H:%M:%S %Y")
target_compile_definitions(mtnet PRIVATE MT_BUILD_TIMESTAMP="${MT_BUILD_TS}")
# --- 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, 40250 format (CLZO/TEA from port_platform) ---
add_library(mtproto STATIC src/proto/proto.cpp)
target_include_directories(mtproto PUBLIC src/proto)
target_link_libraries(mtproto PUBLIC port_platform)
target_compile_features(mtproto PUBLIC cxx_std_20)
# --- port_logic + port_platform: 40250 mirror (extension/src/port) and its platform layer
# (extension/src/platform), docs/PORT-PLAN.md ---
add_subdirectory(src/port)
# 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)
# net_classic_e2e: live 40250 classic handshake -> login -> char select -> PHASE_GAME probe.
add_executable(net_classic_e2e tools/net_classic_e2e.cpp)
target_link_libraries(net_classic_e2e PRIVATE mtnet)
endif()
if(BUILD_TESTING AND MT_HOST_BUILD)
add_executable(dxt_decode_test tests/dxt_decode_test.cpp src/dxt.cpp)
target_include_directories(dxt_decode_test PRIVATE src)
add_test(NAME dxt.decode COMMAND $<TARGET_FILE:dxt_decode_test>)
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 $<TARGET_FILE: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 $<TARGET_FILE: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 $<TARGET_FILE:net_entity_test>)
add_executable(net_state_queue_test tests/net_state_queue_test.cpp)
target_link_libraries(net_state_queue_test PRIVATE mtnet)
add_test(NAME net.state_queue COMMAND $<TARGET_FILE:net_state_queue_test>)
# 40250 "classic" backend (MT_PROTOCOL=classic).
add_executable(net_classic_wire_test tests/net_classic_wire_test.cpp)
target_include_directories(net_classic_wire_test PRIVATE src/net)
target_compile_features(net_classic_wire_test PRIVATE cxx_std_20)
add_test(NAME net.classic_wire COMMAND $<TARGET_FILE:net_classic_wire_test>)
add_executable(net_classic_stream_test tests/net_classic_stream_test.cpp)
target_link_libraries(net_classic_stream_test PRIVATE mtnet)
add_test(NAME net.classic_stream COMMAND $<TARGET_FILE:net_classic_stream_test>)
add_executable(net_classic_session_test tests/net_classic_session_test.cpp)
target_link_libraries(net_classic_session_test PRIVATE mtnet)
add_test(NAME net.classic_session COMMAND $<TARGET_FILE:net_classic_session_test>)
add_executable(net_classic_cipher_test tests/net_classic_cipher_test.cpp)
target_link_libraries(net_classic_cipher_test PRIVATE mtnet)
add_test(NAME net.classic_cipher COMMAND $<TARGET_FILE:net_classic_cipher_test>)
add_executable(net_classic_encstream_test tests/net_classic_encstream_test.cpp)
target_link_libraries(net_classic_encstream_test PRIVATE mtnet)
add_test(NAME net.classic_encstream COMMAND $<TARGET_FILE:net_classic_encstream_test>)
add_executable(net_classic_mark_test tests/net_classic_mark_test.cpp)
target_link_libraries(net_classic_mark_test PRIVATE mtnet)
add_test(NAME net.classic_mark COMMAND $<TARGET_FILE:net_classic_mark_test>)
add_executable(net_text_codec_test tests/net_text_codec_test.cpp)
target_link_libraries(net_text_codec_test PRIVATE mtnet)
add_test(NAME net.text_codec COMMAND $<TARGET_FILE:net_text_codec_test>)
add_executable(net_bounds_test tests/net_bounds_test.cpp)
target_link_libraries(net_bounds_test PRIVATE mtnet)
add_test(NAME net.bounds COMMAND $<TARGET_FILE:net_bounds_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 $<TARGET_FILE: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 $<TARGET_FILE:pack_roundtrip_test>)
add_executable(proto_test tests/proto_test.cpp)
target_link_libraries(proto_test PRIVATE mtproto)
# MT_40250_CLIENT comes from src/port/CMakeLists.txt; skipped (77) without the client, failed under
# MT_ASSETS_STRICT=1.
add_test(NAME proto.item_mob COMMAND $<TARGET_FILE:proto_test> ${MT_40250_CLIENT})
set_tests_properties(proto.item_mob PROPERTIES SKIP_RETURN_CODE 77)
add_executable(proto_item_layout_test tests/proto_item_layout_test.cpp)
target_link_libraries(proto_item_layout_test PRIVATE mtproto)
add_test(NAME proto.item_layout COMMAND $<TARGET_FILE:proto_item_layout_test>)
# The embedded interpreter, when third_party built it (-DMTGODOT_EMBED_PYTHON=ON).
if(TARGET mtpython)
add_executable(py_embed_test tests/py_embed_test.cpp)
target_link_libraries(py_embed_test PRIVATE mtpython)
add_dependencies(py_embed_test mtpython_stdlib)
# The stdlib exactly as the host ships it: python27.zip, read through zipimport (批次 2P step 3).
add_test(NAME python.embed COMMAND $<TARGET_FILE:py_embed_test> "${MT_PYTHON_STDLIB_ZIP}")
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/metin2_model.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/m2_material.cpp
src/dxt.cpp
src/asset_io.cpp
src/texture_util.cpp
src/net/m2_client.cpp
src/proto/proto_node.cpp
src/pack40250_node.cpp
src/python_stdlib.cpp
src/python_host_node.cpp
)
target_compile_features(mtgodot PRIVATE cxx_std_20)
target_link_libraries(mtgodot PRIVATE godot::cpp xrender::libgr2 xrender::formats mtnet mtproto port_platform)
# The embedded interpreter's standard library (批次 2P step 3c). python27.zip and its digest are
# copied next to project.godot so the exporter packs them into the APK/IPA (the export presets'
# include_filter lists them); src/python_stdlib.cpp stages them out of the PCK at runtime.
if(TARGET mtpython_stdlib)
target_compile_definitions(mtgodot PRIVATE MTGODOT_HAVE_PYTHON)
# A target rather than a custom command on the two files: the command that produces them lives in
# third_party/, and a Makefile generator only resolves a generated-file dependency inside the
# directory that declared it. copy_if_different keeps the no-op build a compare.
add_custom_target(mtpython_stdlib_project ALL
COMMAND ${CMAKE_COMMAND} -E copy_if_different
"${MT_PYTHON_STDLIB_ZIP}" "${CMAKE_SOURCE_DIR}/project/python27.zip"
COMMAND ${CMAKE_COMMAND} -E copy_if_different
"${MT_PYTHON_STDLIB_SHA}" "${CMAKE_SOURCE_DIR}/project/python27.zip.sha256"
COMMENT "Copying python27.zip into project/ (res://)")
add_dependencies(mtpython_stdlib_project mtpython_stdlib)
add_dependencies(mtgodot mtpython_stdlib_project)
endif()
# Godot's .gdextension expects, per platform:
# project/bin/libmtgodot.macos.template_{debug,release}.dylib
# 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
PREFIX "lib"
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"
)
-72
View File
@@ -1,72 +0,0 @@
#include "asset_io.h"
#include <godot_cpp/classes/file_access.hpp>
#include "platform/PackBackend.h"
#include <cstring>
#include <string>
#include <vector>
using namespace godot;
namespace mtgodot {
namespace {
bool pack_name(const String &path, std::string &name) {
if (!path.begins_with(PACK_SCHEME)) {
return false;
}
name = path.substr(String(PACK_SCHEME).length()).utf8().get_data();
return true;
}
} // namespace
PackedByteArray read_file(const String &path) {
if (path.is_empty()) {
return PackedByteArray();
}
std::string name;
if (pack_name(path, name)) {
std::vector<uint8_t> bytes;
PackedByteArray out;
if (mtpack40250::read(name, bytes)) {
out.resize((int64_t)bytes.size());
if (!bytes.empty()) {
std::memcpy(out.ptrw(), bytes.data(), bytes.size());
}
}
return out;
}
// FileAccess::get_file_as_bytes handles res:// / user:// / absolute OS paths.
return FileAccess::get_file_as_bytes(path);
}
bool file_exists(const String &path) {
std::string name;
if (pack_name(path, name)) {
return mtpack40250::exists(name);
}
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
-38
View File
@@ -1,38 +0,0 @@
// 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.
// - pack://<40250 path> (e.g. pack://locale/en/item_proto, pack://d:/ymir work/pc/...): the 40250 packs,
// read through the ported CEterPackManager once Metin2Pack.initialize(<Client dir>) has run
// (platform/PackBackend.h). The path after pack:// is passed as UTF-8.
#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 {
inline constexpr const char *PACK_SCHEME = "pack://";
// 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
+2 -2
View File
@@ -4,7 +4,7 @@
#include <algorithm> #include <algorithm>
#include <cstring> #include <cstring>
namespace mtgodot { namespace mtimage {
namespace { namespace {
inline uint32_t rd_le32(const uint8_t* p) { inline uint32_t rd_le32(const uint8_t* p) {
@@ -209,4 +209,4 @@ Image load_dds_path(const char* path) {
return decode(buf.data(), buf.size()); return decode(buf.data(), buf.size());
} }
} // namespace mtgodot } // namespace mtimage
+3 -3
View File
@@ -1,5 +1,5 @@
// dxt.{h,cpp} — ported verbatim from xrender-poc/engine/dxt.{h,cpp} // dxt.{h,cpp} — ported verbatim from xrender-poc/engine/dxt.{h,cpp}
// (namespace engine -> mtgodot). DDS DXT1/3/5 + masked 16/24/32-bit RGB -> RGBA8, level 0. // (namespace engine -> mtimage). DDS DXT1/3/5 + masked 16/24/32-bit RGB -> RGBA8, level 0.
// engine/dxt —— DDS(DXT1/3/5) → RGBA8 软解。见 docs/steps/M1-static-render.md T3 // engine/dxt —— DDS(DXT1/3/5) → RGBA8 软解。见 docs/steps/M1-static-render.md T3
// 自己写 ~150 行绕开 reuse/EterImageLib 的 windows.h 依赖(M1 风险表允许)。 // 自己写 ~150 行绕开 reuse/EterImageLib 的 windows.h 依赖(M1 风险表允许)。
// 对拍时两侧都喂软解 RGBA,不被 GPU S3TC 的 bit 级差异污染。 // 对拍时两侧都喂软解 RGBA,不被 GPU S3TC 的 bit 级差异污染。
@@ -7,7 +7,7 @@
#include <cstdint> #include <cstdint>
#include <vector> #include <vector>
namespace mtgodot { namespace mtimage {
struct Image { struct Image {
uint16_t w = 0, h = 0; uint16_t w = 0, h = 0;
@@ -24,4 +24,4 @@ struct Image {
Image load_dds(const uint8_t* bytes, size_t len); Image load_dds(const uint8_t* bytes, size_t len);
Image load_dds_path(const char* path); Image load_dds_path(const char* path);
} // namespace mtgodot } // namespace mtimage
-451
View File
@@ -1,451 +0,0 @@
#include "environment_builder.h"
#include "asset_io.h"
#include "dxt.h"
#include <godot_cpp/classes/environment.hpp>
#include <godot_cpp/classes/image.hpp>
#include <godot_cpp/classes/image_texture.hpp>
#include <godot_cpp/classes/procedural_sky_material.hpp>
#include <godot_cpp/classes/shader.hpp>
#include <godot_cpp/classes/shader_material.hpp>
#include <godot_cpp/classes/sky.hpp>
#include <godot_cpp/classes/texture2d.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]; }
const char *SRC_SKYBOX = R"(shader_type sky;
render_mode use_debanding;
uniform sampler2D front_tex : source_color, filter_linear_mipmap_anisotropic;
uniform sampler2D back_tex : source_color, filter_linear_mipmap_anisotropic;
uniform sampler2D left_tex : source_color, filter_linear_mipmap_anisotropic;
uniform sampler2D right_tex : source_color, filter_linear_mipmap_anisotropic;
uniform sampler2D top_tex : source_color, filter_linear_mipmap_anisotropic;
uniform sampler2D bottom_tex : source_color, filter_linear_mipmap_anisotropic;
uniform bool has_front = false;
uniform bool has_back = false;
uniform bool has_left = false;
uniform bool has_right = false;
uniform bool has_top = false;
uniform bool has_bottom = false;
uniform vec4 gradient_top = vec4(0.2, 0.3, 0.6, 1.0);
uniform vec4 gradient_horizon = vec4(0.5, 0.6, 0.8, 1.0);
uniform vec4 gradient_bottom = vec4(0.2, 0.2, 0.25, 1.0);
uniform sampler2D cloud_tex : source_color, repeat_enable, filter_linear_mipmap_anisotropic;
uniform bool has_cloud = false;
uniform vec2 cloud_scale = vec2(2000.0, 2000.0);
uniform float cloud_height = 300.0;
uniform vec2 cloud_texture_scale = vec2(4.0, 4.0);
uniform vec2 cloud_speed = vec2(0.001, 0.001);
uniform vec4 cloud_tint = vec4(0.0, 0.0, 0.0, 0.0);
vec3 gradient_color(vec3 direction) {
float height = clamp(direction.y * 0.5 + 0.5, 0.0, 1.0);
if (height < 0.5)
return mix(gradient_bottom.rgb, gradient_horizon.rgb, height * 2.0);
return mix(gradient_horizon.rgb, gradient_top.rgb, (height - 0.5) * 2.0);
}
vec3 apply_cloud(vec3 color, vec3 direction) {
if (!has_cloud || direction.y <= 0.001 || cloud_height <= 0.0 ||
cloud_scale.x <= 0.001 || cloud_scale.y <= 0.001)
return color;
// ClientVS22 renders a finite horizontal quad at camera.z + CloudHeight.
// After the Metin2 -> Godot conversion this is camera.y + height. The
// source quad maps source +Y -> U=0 and source +X -> V=1; source Y is
// Godot -Z, so keep that orientation here.
float distance_to_cloud = cloud_height / direction.y;
vec2 plane = direction.xz * distance_to_cloud;
if (abs(plane.x) > cloud_scale.x || abs(plane.y) > cloud_scale.y)
return color;
vec2 uv = vec2(
0.5 + 0.5 * plane.y / cloud_scale.y,
0.5 + 0.5 * plane.x / cloud_scale.x);
uv = fract(uv * cloud_texture_scale + TIME * cloud_speed);
// SkyBox.cpp uses MODULATEINVALPHA_ADDCOLOR followed by ONE /
// INVSRCCOLOR blending: cloud.rgb + sky * (1 - cloud.rgb).
vec4 texel = texture(cloud_tex, uv);
vec3 cloud_rgb = clamp(texel.rgb * (1.0 - cloud_tint.a) + cloud_tint.rgb,
0.0, 1.0);
return cloud_rgb + color * (1.0 - cloud_rgb);
}
void sky() {
vec3 direction = normalize(EYEDIR);
vec3 color = gradient_color(direction);
float ax = abs(direction.x);
float ay = abs(direction.y);
float az = abs(direction.z);
vec2 uv;
// ClientVS22 uses Metin2 Z-up faces. After x,z,-y conversion:
// front=+Z, back=-Z, left=+X, right=-X, top=+Y, bottom=-Y.
if (az >= ax && az >= ay) {
if (direction.z > 0.0) {
uv = vec2(0.5 - 0.5 * direction.x / az,
0.5 - 0.5 * direction.y / az);
if (has_front)
color = texture(front_tex, uv).rgb;
} else {
uv = vec2(0.5 + 0.5 * direction.x / az,
0.5 - 0.5 * direction.y / az);
if (has_back)
color = texture(back_tex, uv).rgb;
}
} else if (ax >= ay) {
if (direction.x > 0.0) {
uv = vec2(0.5 + 0.5 * direction.z / ax,
0.5 - 0.5 * direction.y / ax);
if (has_left)
color = texture(left_tex, uv).rgb;
} else {
uv = vec2(0.5 - 0.5 * direction.z / ax,
0.5 - 0.5 * direction.y / ax);
if (has_right)
color = texture(right_tex, uv).rgb;
}
} else if (direction.y > 0.0) {
uv = vec2(0.5 - 0.5 * direction.x / ay,
0.5 - 0.5 * direction.z / ay);
if (has_top)
color = texture(top_tex, uv).rgb;
} else {
uv = vec2(0.5 - 0.5 * direction.x / ay,
0.5 - 0.5 * direction.z / ay);
if (has_bottom)
color = texture(bottom_tex, uv).rgb;
}
COLOR = apply_cloud(color, direction);
}
)";
Ref<ImageTexture> solid_texture(const Color &color) {
PackedByteArray pixels;
pixels.resize(4);
uint8_t *p = pixels.ptrw();
p[0] = (uint8_t)std::round(std::clamp(color.r, 0.0f, 1.0f) * 255.0f);
p[1] = (uint8_t)std::round(std::clamp(color.g, 0.0f, 1.0f) * 255.0f);
p[2] = (uint8_t)std::round(std::clamp(color.b, 0.0f, 1.0f) * 255.0f);
p[3] = (uint8_t)std::round(std::clamp(color.a, 0.0f, 1.0f) * 255.0f);
Ref<godot::Image> image = godot::Image::create_from_data(
1, 1, false, godot::Image::FORMAT_RGBA8, pixels);
image->generate_mipmaps();
return ImageTexture::create_from_image(image);
}
Ref<ImageTexture> load_texture(const std::string &name, const fmt::AssetResolver *resolver) {
if (name.empty() || resolver == nullptr)
return Ref<ImageTexture>();
const std::string path = resolver->resolve(name, nullptr);
if (path.empty())
return Ref<ImageTexture>();
Ref<godot::Image> image;
const String godot_path(path.c_str());
if (godot_path.get_extension().to_lower() == "dds") {
const mtgodot::Image decoded = mtgodot::dds_from_file(godot_path);
if (!decoded.ok())
return Ref<ImageTexture>();
PackedByteArray pixels;
pixels.resize((int64_t)decoded.rgba.size());
std::copy(decoded.rgba.begin(), decoded.rgba.end(), pixels.ptrw());
image = godot::Image::create_from_data(decoded.w, decoded.h, false,
godot::Image::FORMAT_RGBA8, pixels);
} else {
// Image::load_from_file cannot open the pack:// virtual filesystem.
if (godot_path.begins_with("pack://")) {
PackedByteArray bytes = mtgodot::read_file(godot_path);
image.instantiate();
if (godot_path.get_extension().to_lower() == "tga")
image->load_tga_from_buffer(bytes);
else if (godot_path.get_extension().to_lower() == "png")
image->load_png_from_buffer(bytes);
else if (godot_path.get_extension().to_lower() == "jpg")
image->load_jpg_from_buffer(bytes);
} else {
image = godot::Image::load_from_file(godot_path);
}
}
if (image.is_null() || image->is_empty())
return Ref<ImageTexture>();
image->generate_mipmaps();
return ImageTexture::create_from_image(image);
}
} // namespace
EnvNodes apply_environment(const fmt::Environment &env, Node *parent,
const fmt::AssetResolver *resolver) {
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);
}
out.sun->set_cull_mask(1u << 0); // background / terrain layer
out.sun->set_visible(env.dir_light.bg_enable);
// .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);
// ClientVS22 keeps the character light separate from the background light
// (MapOutdoor::OnBeginEnvironment -> SpeedTree::SetLight). Godot's light
// cull mask is the closest exact scene-level equivalent: static world
// geometry is layer 1, Metin2Model geometry is layer 2.
out.character_light = Object::cast_to<DirectionalLight3D>(
parent->get_node_or_null(NodePath("CharacterLight")));
if (!out.character_light) {
out.character_light = memnew(DirectionalLight3D);
out.character_light->set_name("CharacterLight");
parent->add_child(out.character_light);
}
out.character_light->set_cull_mask(1u << 1);
out.character_light->set_visible(env.dir_light.ch_enable);
out.character_light->set_transform(Transform3D(b, Vector3(0, 0, 0)));
out.character_light->set_color(rgba(env.dir_light.ch_diffuse));
out.character_light->set_param(Light3D::PARAM_ENERGY,
std::clamp(0.9f + luma(env.dir_light.ch_diffuse) * 0.4f, 0.7f, 1.6f));
out.character_light->set_shadow(false);
out.character_light->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)。
// 参考端的 texture mode 是独立六面 quad;Godot Sky shader 用 EYEDIR 采样
// 同一套六面图,坐标和 UV 依照 EterLib/SkyBox.cpp 的面顶点顺序转换。
int sky_face_count = 0;
bool sky_texture_applied = false;
bool sky_cloud_applied = false;
{
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();
std::array<Ref<ImageTexture>, 6> faces;
std::array<bool, 6> face_loaded{};
for (int i = 0; i < 6; ++i) {
if (env.sky.face_textures[i].empty())
continue;
faces[i] = load_texture(env.sky.face_textures[i], resolver);
face_loaded[i] = faces[i].is_valid();
if (face_loaded[i])
++sky_face_count;
}
Ref<ImageTexture> cloud_texture;
if (!env.sky.cloud_texture.empty())
cloud_texture = load_texture(env.sky.cloud_texture, resolver);
const bool use_texture_sky = env.sky.texture_render_mode && sky_face_count > 0;
sky_texture_applied = use_texture_sky;
if (use_texture_sky) {
Ref<Shader> shader;
shader.instantiate();
shader->set_code(String(SRC_SKYBOX));
Ref<ShaderMaterial> material;
material.instantiate();
material->set_shader(shader);
Color fallback = g.empty() ? Color(0.35f, 0.45f, 0.7f, 1.0f) : rgba(g.front());
Color horizon = g.size() < 2 ? fallback : rgba(g[g.size() / 2]);
Color ground = g.empty() ? Color(0.2f, 0.2f, 0.25f, 1.0f) : rgba(g.back());
material->set_shader_parameter("gradient_top", fallback);
material->set_shader_parameter("gradient_horizon", horizon);
material->set_shader_parameter("gradient_bottom", ground);
static constexpr const char *kFaceParams[6] = {
"front_tex", "back_tex", "left_tex", "right_tex", "top_tex", "bottom_tex"};
static constexpr const char *kFaceFlags[6] = {
"has_front", "has_back", "has_left", "has_right", "has_top", "has_bottom"};
for (int i = 0; i < 6; ++i) {
if (!faces[i].is_valid())
faces[i] = solid_texture(i == 5 ? ground : horizon);
material->set_shader_parameter(kFaceParams[i], faces[i]);
material->set_shader_parameter(kFaceFlags[i], face_loaded[i]);
}
if (cloud_texture.is_valid()) {
material->set_shader_parameter("cloud_tex", cloud_texture);
material->set_shader_parameter("has_cloud", true);
material->set_shader_parameter("cloud_scale", Vector2(
std::max(env.sky.cloud_scale[0] * (float)fmt::m2coord::CM_TO_M, 0.01f),
std::max(env.sky.cloud_scale[1] * (float)fmt::m2coord::CM_TO_M, 0.01f)));
material->set_shader_parameter("cloud_height",
std::max(env.sky.cloud_height * (float)fmt::m2coord::CM_TO_M, 0.01f));
material->set_shader_parameter("cloud_texture_scale", Vector2(
env.sky.cloud_texture_scale[0], env.sky.cloud_texture_scale[1]));
material->set_shader_parameter("cloud_speed", Vector2(
env.sky.cloud_speed[0], env.sky.cloud_speed[1]));
Color tint = Color(0, 0, 0, 0);
if (!env.sky.cloud_color.empty())
tint = rgba(env.sky.cloud_color.front());
material->set_shader_parameter("cloud_tint", tint);
sky_cloud_applied = true;
}
sky->set_material(material);
} else {
sky->set_material(psm);
}
// Reference SkyBox scrolls clouds in its own renderer. Godot's procedural
// sky has no equivalent UV-speed control, but sky_cover preserves the real
// cloud asset and alpha/color instead of silently dropping CloudTextureFileName.
if (!env.sky.cloud_texture.empty() && !use_texture_sky) {
if (cloud_texture.is_valid()) {
psm->set_sky_cover(cloud_texture);
Color tint = Color(1, 1, 1, 1);
if (!env.sky.cloud_color.empty())
tint = rgba(env.sky.cloud_color.front());
psm->set_sky_cover_modulate(tint);
sky_cloud_applied = true;
}
}
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);
// Keep parsed reference values visible to the runtime/debug probe even where
// Godot has no 1:1 mapping (six cube faces, D3D blend factors, cloud motion,
// and lens flare). This also prevents a future builder from silently losing
// the fields while the renderer equivalent is being implemented.
out.world_env->set_meta("msenv_sky_texture_mode", env.sky.texture_render_mode);
out.world_env->set_meta("msenv_sky_face_count", sky_face_count);
out.world_env->set_meta("msenv_sky_texture_mode_applied", sky_texture_applied);
out.world_env->set_meta("msenv_sky_cloud_applied", sky_cloud_applied);
out.world_env->set_meta("msenv_sky_scale",
Vector3(env.sky.scale[0], env.sky.scale[1], env.sky.scale[2]));
out.world_env->set_meta("msenv_cloud_scale",
Vector2(env.sky.cloud_scale[0], env.sky.cloud_scale[1]));
out.world_env->set_meta("msenv_cloud_height", env.sky.cloud_height);
out.world_env->set_meta("msenv_cloud_texture_scale",
Vector2(env.sky.cloud_texture_scale[0], env.sky.cloud_texture_scale[1]));
out.world_env->set_meta("msenv_cloud_speed",
Vector2(env.sky.cloud_speed[0], env.sky.cloud_speed[1]));
out.world_env->set_meta("msenv_filter_enabled", env.filter.enable);
out.world_env->set_meta("msenv_filter_color", rgba(env.filter.color));
out.world_env->set_meta("msenv_filter_alpha_src", env.filter.alpha_src);
out.world_env->set_meta("msenv_filter_alpha_dest", env.filter.alpha_dest);
out.world_env->set_meta("msenv_lens_flare_enabled", env.lens_flare.enable);
out.world_env->set_meta("msenv_lens_flare_texture", env.lens_flare.main_flare_texture.c_str());
out.world_env->set_meta("msenv_background_light_enabled", env.dir_light.bg_enable);
out.world_env->set_meta("msenv_character_light_enabled", env.dir_light.ch_enable);
out.world_env->set_meta("msenv_background_light_ambient", rgba(env.dir_light.bg_ambient));
out.world_env->set_meta("msenv_character_light_ambient", rgba(env.dir_light.ch_ambient));
// MapUtil.cpp defaults; Environment_Load has no wind token to override these.
out.world_env->set_meta("msenv_wind_strength", 0.2f);
out.world_env->set_meta("msenv_wind_random", 0.0f);
out.world_env->set_environment(e);
return out;
}
} // namespace mtgodot
-26
View File
@@ -1,26 +0,0 @@
#pragma once
#include <godot_cpp/classes/directional_light3d.hpp>
#include <godot_cpp/classes/world_environment.hpp>
#include <asset_resolver.h>
#include <environment.h>
// W5 —— .msenv(已由 formats/environment 解析)-> Godot 光照 / 天空 / 雾 / 色调。
// SHINSOO §9-W5。背景 / 角色方向光按参考端分成两个可见性层;云贴图接入
// ProceduralSkyMaterial 的 sky cover;texture-mode 下再用 Godot sky shader 叠加
// 参考端的有限云层平面与 UV 滚动。六面天空、滤色和 lens flare 仍保持显式待办,
// 不把不等价的近似标成完成。
namespace mtgodot {
struct EnvNodes {
godot::DirectionalLight3D *sun = nullptr;
godot::DirectionalLight3D *character_light = nullptr;
godot::WorldEnvironment *world_env = nullptr;
};
// 在 parent 下建 / 配 DirectionalLight3D + WorldEnvironment。已存在则复用。
EnvNodes apply_environment(const fmt::Environment &env, godot::Node *parent,
const fmt::AssetResolver *resolver = nullptr);
} // namespace mtgodot
-244
View File
@@ -1,244 +0,0 @@
#include "gr2_bridge.h"
#include <godot_cpp/classes/array_mesh.hpp>
#include <godot_cpp/classes/mesh.hpp>
#include <godot_cpp/classes/skeleton3d.hpp>
#include <godot_cpp/classes/skin.hpp>
#include <godot_cpp/core/math.hpp>
#include <godot_cpp/core/memory.hpp>
#include <godot_cpp/variant/packed_float32_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/utility_functions.hpp>
#include <vector>
using namespace godot;
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) {
// 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).
Basis b(
Vector3(m[0], m[1], m[2]),
Vector3(m[4], m[5], m[6]),
Vector3(m[8], m[9], m[10]));
return Transform3D(b, Vector3(m[12], m[13], m[14]));
}
Transform3D make_conv(float unit_scale, bool flip_z) {
// Z-up -> Y-up: rotate -90 deg about X. Then uniform scale. Optional Z flip.
Basis b;
b = b.rotated(Vector3(1, 0, 0), Math::deg_to_rad(-90.0));
b = b.scaled(Vector3(unit_scale, unit_scale, flip_z ? -unit_scale : unit_scale));
return Transform3D(b, Vector3());
}
Skeleton3D *build_skeleton(const gr2::Skeleton &sk) {
if (sk.bones.empty()) {
return nullptr;
}
Skeleton3D *skel = memnew(Skeleton3D);
skel->set_name("Skeleton3D");
const Transform3D ip = gr2_to_godot(sk.initial_placement);
for (size_t i = 0; i < sk.bones.size(); ++i) {
skel->add_bone(String(sk.bones[i].name.c_str()));
}
for (size_t i = 0; i < sk.bones.size(); ++i) {
const gr2::Bone &bn = sk.bones[i];
skel->set_bone_parent((int)i, bn.parent);
Transform3D rest = gr2_to_godot(bn.local_transform);
if (bn.parent < 0) {
rest = ip * rest; // fold initial_placement into root bones
}
skel->set_bone_rest((int)i, rest);
skel->reset_bone_pose((int)i);
}
return skel;
}
Ref<Skin> build_skin(const gr2::Skeleton &sk) {
Ref<Skin> skin;
skin.instantiate();
for (size_t i = 0; i < sk.bones.size(); ++i) {
skin->add_bind((int)i, gr2_to_godot(sk.bones[i].inverse_world));
skin->set_bind_name((int)i, StringName(sk.bones[i].name.c_str()));
}
return skin;
}
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;
am.instantiate();
am->set_name("ArrayMesh");
bool have_bounds = false;
// vertex arrays are per gr2 mesh; multiple parts of one mesh reuse them.
for (const RenderPart &part : parts) {
const gr2::Mesh &m = fi.meshes[part.mesh];
const int vcount = (int)m.vertices.size();
PackedVector3Array pos;
PackedVector3Array nrm;
PackedVector2Array uv;
PackedInt32Array bones;
PackedFloat32Array weights;
pos.resize(vcount);
nrm.resize(vcount);
uv.resize(vcount);
bones.resize(vcount * 4);
weights.resize(vcount * 4);
Vector3 *pos_w = pos.ptrw();
Vector3 *nrm_w = nrm.ptrw();
Vector2 *uv_w = uv.ptrw();
int32_t *bn_w = bones.ptrw();
float *wt_w = weights.ptrw();
auto slot_to_bone = [&](int slot) -> int {
if (slot < 0 || slot >= (int)m.bone_bindings.size()) {
return 0;
}
int b = m.bone_bindings[slot];
return (b < 0) ? 0 : b;
};
const int rigid_bone = m.rigid ? slot_to_bone(m.bone_bindings.empty() ? -1 : 0) : 0;
for (int i = 0; i < vcount; ++i) {
const gr2::Vertex &v = m.vertices[i];
pos_w[i] = Vector3(v.pos[0], v.pos[1], v.pos[2]);
nrm_w[i] = Vector3(v.normal[0], v.normal[1], v.normal[2]);
uv_w[i] = Vector2(v.uv0[0], v.uv0[1]);
if (!have_bounds) {
out_bounds.position = pos_w[i];
out_bounds.size = Vector3();
have_bounds = true;
} else {
out_bounds = out_bounds.expand(pos_w[i]);
}
if (m.rigid) {
bn_w[i * 4 + 0] = rigid_bone;
bn_w[i * 4 + 1] = bn_w[i * 4 + 2] = bn_w[i * 4 + 3] = 0;
wt_w[i * 4 + 0] = 1.0f;
wt_w[i * 4 + 1] = wt_w[i * 4 + 2] = wt_w[i * 4 + 3] = 0.0f;
continue;
}
float wsum = 0.0f;
for (int k = 0; k < 4; ++k) {
wsum += v.bone_weight[k];
}
for (int k = 0; k < 4; ++k) {
bn_w[i * 4 + k] = slot_to_bone(v.bone_index[k]);
wt_w[i * 4 + k] = (wsum > 0.0f) ? (v.bone_weight[k] / (float)wsum) : (k == 0 ? 1.0f : 0.0f);
}
}
// 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;
idx.resize((int)ic);
int32_t *idx_w = idx.ptrw();
if (flip_winding) {
for (uint32_t t = 0; t + 2 < ic; t += 3) {
idx_w[t + 0] = (int)m.indices[ib + t + 0];
idx_w[t + 1] = (int)m.indices[ib + t + 2];
idx_w[t + 2] = (int)m.indices[ib + t + 1];
}
} else {
for (uint32_t t = 0; t < ic; ++t) {
idx_w[t] = (int)m.indices[ib + t];
}
}
Array arrays;
arrays.resize(Mesh::ARRAY_MAX);
arrays[Mesh::ARRAY_VERTEX] = pos;
arrays[Mesh::ARRAY_NORMAL] = nrm;
arrays[Mesh::ARRAY_TEX_UV] = uv;
arrays[Mesh::ARRAY_BONES] = bones;
arrays[Mesh::ARRAY_WEIGHTS] = weights;
arrays[Mesh::ARRAY_INDEX] = idx;
am->add_surface_from_arrays(Mesh::PRIMITIVE_TRIANGLES, arrays);
String nm = m.name.empty() ? String("surf_") + itos(part.mesh) : 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) {
out_bounds = AABB();
}
return am;
}
} // namespace mtgodot
-65
View File
@@ -1,65 +0,0 @@
#pragma once
// gr2_bridge — libgr2 POD views -> Godot scene objects.
//
// Coordinate handling (the one place it lives; see docs/GODOT-POC-PLAN.md §02):
// - gr2 Mat4 is row-major, row-vector math, translation in elements 12..14.
// - A plain 4x4 transpose maps it to a Godot Transform3D (column-vector,
// translation in .origin). gr2_to_godot() does exactly that.
// - Z-up(cm) -> Y-up(m) and optional handedness flip are NOT applied here;
// they go on the Metin2Model node transform (make_conv()).
#include <godot_cpp/classes/ref.hpp>
#include <godot_cpp/variant/aabb.hpp>
#include <godot_cpp/variant/transform3d.hpp>
#include <gr2/gr2.h>
#include <cstdint>
#include <vector>
namespace godot {
class Skeleton3D;
class Skin;
class ArrayMesh;
} // namespace godot
namespace mtgodot {
// 4x4 transpose: gr2 row-major/row-vector -> Godot Transform3D.
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).
godot::Transform3D make_conv(float unit_scale, bool flip_z);
// Build a Skeleton3D (rest pose from bone local transforms; initial_placement
// folded into root bones). Returns nullptr if the skeleton has no bones.
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:
// bind i -> bone i, pose = gr2_to_godot(bone[i].inverse_world)
godot::Ref<godot::Skin> build_skin(const gr2::Skeleton &sk);
// One ArrayMesh with a surface per RenderPart (see build_parts).
// ARRAY_BONES values are skeleton bone indices (mesh slot -> bone via
// mesh.bone_bindings). Rigid meshes are bound rigidly to bone_bindings[0].
// Fills out_bounds with the untransformed vertex AABB.
godot::Ref<godot::ArrayMesh> build_mesh(const gr2::FileInfo &fi,
const std::vector<RenderPart> &parts, bool flip_winding,
godot::AABB &out_bounds);
} // namespace mtgodot
-276
View File
@@ -1,276 +0,0 @@
#include "m2_material.h"
#include <godot_cpp/classes/image_texture.hpp>
#include <godot_cpp/classes/shader.hpp>
#include <godot_cpp/classes/shader_material.hpp>
#include <godot_cpp/classes/texture2d.hpp>
#include <godot_cpp/variant/color.hpp>
using namespace godot;
namespace mtgodot {
namespace {
// blend_mix shader: covers opaque (mode 0, alpha-scissor) and alpha (mode 1).
const char *SRC_MIX = 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; // 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() {
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)
if (mode == 2 && c.a < alpha_scissor) {
discard;
}
ALBEDO = c.rgb;
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);
}
}
)";
// blend_add shader: additive glow (effects, enchant overlays).
const char *SRC_ADD = R"(shader_type spatial;
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);
void fragment() {
vec4 c = use_texture ? texture(albedo_tex, UV) : vec4(1.0);
c *= modulate; // no vertex color: Metin2 PC meshes carry none (ARRAY_COLOR absent)
ALBEDO = c.rgb * c.a;
ALPHA = 1.0;
}
)";
// Additive surfaces need the same vertex deformation as opaque/alpha surfaces.
// Keeping this as a separate shader preserves blend_add/unshaded render modes.
const char *SRC_ADD_SKIN = R"(shader_type spatial;
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> s_opaque[4];
Ref<Shader> shader_for(bool additive, bool skinned, bool two_sided, bool opaque) {
if (opaque) {
Ref<Shader> &solid = s_opaque[(skinned ? 2 : 0) + (two_sided ? 1 : 0)];
if (solid.is_null()) {
String code = skinned ? SRC_SKIN : SRC_MIX;
// Writing ALPHA puts even alpha=1 into Godot's transparent pass.
// Solid faces must write depth before the hair cutout is drawn.
code = code.replace("ALPHA = ((mode == 1) ? c.a : 1.0) * lod_fade;",
"if (lod_fade < fract(sin(dot(FRAGCOORD.xy, vec2(12.9898, 78.233))) * 43758.5453)) { discard; }");
if (two_sided)
code = code.replace("cull_back", "cull_disabled");
solid.instantiate();
solid->set_code(code);
}
return solid;
}
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()) {
slot.instantiate();
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;
}
} // namespace
Ref<ShaderMaterial> make_material(const MaterialDesc &d) {
Ref<ShaderMaterial> m;
m.instantiate();
const bool additive = (d.blend == BlendMode::Add);
const bool skinned = d.skinned;
m->set_shader(shader_for(additive, skinned, d.two_sided,
d.blend == BlendMode::Opaque || d.blend == BlendMode::AlphaTest));
if (skinned && d.bones_tex.is_valid()) {
m->set_shader_parameter("bones_tex", d.bones_tex);
}
const bool has_tex = d.albedo.is_valid();
m->set_shader_parameter("use_texture", has_tex);
if (has_tex) {
m->set_shader_parameter("albedo_tex", d.albedo);
}
m->set_shader_parameter("modulate", has_tex ? Color(1, 1, 1, 1) : Color(0.8, 0.8, 0.82, 1));
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);
int mode = 0;
if (d.blend == BlendMode::Alpha) {
mode = 1;
} else if (d.blend == BlendMode::AlphaTest) {
mode = 2;
}
m->set_shader_parameter("mode", mode);
}
if (d.blend == BlendMode::Alpha) {
m->set_render_priority(1);
} else if (additive) {
m->set_render_priority(2);
}
return m;
}
void cleanup_material_shaders() {
for (auto &shader : s_opaque)
shader.unref();
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
-59
View File
@@ -1,59 +0,0 @@
#pragma once
// m2_material — Metin2-style forward material as a Godot ShaderMaterial.
//
// M2.5 infrastructure. The gr2 fixed-function texture-stage / blend data is not
// yet exposed by libgr2's POD API (see docs/GODOT-POC-PLAN.md §M2.5 gap list),
// so callers pick the blend mode heuristically for now. The shader itself
// already covers the common Metin2 cases: modulate(tex, vertex_color) + light,
// alpha-test, alpha-blend, additive.
#include <godot_cpp/classes/ref.hpp>
namespace godot {
class ShaderMaterial;
class Texture2D;
class ImageTexture;
} // namespace godot
namespace mtgodot {
enum class BlendMode {
Opaque, // blend_mix, ALPHA=1, no discard
Alpha, // blend_mix, sorted, uses texture alpha
AlphaTest, // blend_mix, discard below alpha_scissor (cutout)
Add, // blend_add (glow / effects)
};
struct MaterialDesc {
godot::Ref<godot::Texture2D> albedo; // null -> flat white
BlendMode blend = BlendMode::Opaque;
float alpha_scissor = 0.5f;
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);
// 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
-856
View File
@@ -1,856 +0,0 @@
#include "metin2_anim.h"
#include "asset_io.h"
#include "gr2_bridge.h"
#include "metin2_model.h"
#include <godot_cpp/classes/skeleton3d.hpp>
#include <godot_cpp/core/class_db.hpp>
#include <godot_cpp/variant/utility_functions.hpp>
#include <cmath>
#include <string>
#include <list>
#include <godot_cpp/classes/mesh_instance3d.hpp>
#include <godot_cpp/classes/os.hpp>
#include <godot_cpp/classes/skin.hpp>
#include <godot_cpp/classes/file_access.hpp>
#include <godot_cpp/variant/array.hpp>
#include <godot_cpp/variant/dictionary.hpp>
#include <godot_cpp/variant/packed_int32_array.hpp>
#include <combo_table.h>
using namespace godot;
namespace mtgodot {
namespace {
struct ClipEntry {
String path;
uint64_t modified;
size_t bytes;
std::shared_ptr<const gr2::File> file;
};
// Immutable decoded clips are shared, not playback time/pose/event cursors.
// Main-thread animation loading only; no Godot objects survive in this cache.
std::list<ClipEntry> clip_cache;
size_t clip_bytes = 0;
uint64_t clip_hits = 0, clip_misses = 0;
constexpr size_t CLIP_LIMIT = 32, CLIP_BYTE_LIMIT = 64 * 1024 * 1024;
struct MsaEntry {
String path;
uint64_t modified;
fmt::Msa metadata;
};
std::list<MsaEntry> msa_cache;
uint64_t msa_hits = 0, msa_misses = 0;
// Keep events immutable in the cache; reload copies them into each actor.
bool cached_msa(const String &path, fmt::Msa &metadata, std::string *error) {
const uint64_t modified = FileAccess::get_modified_time(path);
for (auto it = msa_cache.begin(); it != msa_cache.end(); ++it) {
if (it->path != path) continue;
if (it->modified == modified) {
++msa_hits;
metadata = it->metadata;
msa_cache.splice(msa_cache.begin(), msa_cache, it);
return true;
}
msa_cache.erase(it);
break;
}
++msa_misses;
if (!fmt::parse_msa_file(std::string(path.utf8().get_data()), metadata, error)) return false;
if (msa_cache.size() >= 128) msa_cache.pop_back();
msa_cache.push_front({path, modified, metadata});
return true;
}
std::shared_ptr<const gr2::File> cached_clip(const String &path, gr2::LoadError *err) {
uint64_t modified = FileAccess::get_modified_time(path);
for (auto it = clip_cache.begin(); it != clip_cache.end(); ++it) {
if (it->path != path) continue;
if (it->modified == modified) {
++clip_hits;
clip_cache.splice(clip_cache.begin(), clip_cache, it);
return clip_cache.front().file;
}
clip_bytes -= it->bytes;
clip_cache.erase(it);
break;
}
++clip_misses;
auto loaded = gr2_from_file(path, err);
if (!loaded) return {};
size_t bytes = 0;
for (size_t i = 0; i < loaded->sections().size(); ++i) bytes += loaded->section_bytes(i).size();
auto file = std::make_shared<const gr2::File>(std::move(*loaded));
if (bytes <= CLIP_BYTE_LIMIT) {
while (!clip_cache.empty() && (clip_cache.size() >= CLIP_LIMIT || clip_bytes + bytes > CLIP_BYTE_LIMIT)) {
clip_bytes -= clip_cache.back().bytes;
clip_cache.pop_back();
}
clip_cache.push_front({path, modified, bytes, file});
clip_bytes += bytes;
}
return file;
}
}
Dictionary Metin2AnimPlayer::get_clip_cache_stats() {
Dictionary d;
d["hits"] = clip_hits;
d["misses"] = clip_misses;
d["entries"] = static_cast<int64_t>(clip_cache.size());
d["section_bytes"] = static_cast<int64_t>(clip_bytes);
d["msa_hits"] = msa_hits;
d["msa_misses"] = msa_misses;
d["msa_entries"] = static_cast<int64_t>(msa_cache.size());
return d;
}
void Metin2AnimPlayer::clear_clip_cache() {
clip_cache.clear();
clip_bytes = 0;
clip_hits = clip_misses = 0;
msa_cache.clear();
msa_hits = msa_misses = 0;
}
Metin2AnimPlayer::Metin2AnimPlayer() {}
Metin2AnimPlayer::~Metin2AnimPlayer() = default;
void Metin2AnimPlayer::_bind_methods() {
ClassDB::bind_static_method("Metin2AnimPlayer", D_METHOD("get_clip_cache_stats"), &Metin2AnimPlayer::get_clip_cache_stats);
ClassDB::bind_static_method("Metin2AnimPlayer", D_METHOD("clear_clip_cache"), &Metin2AnimPlayer::clear_clip_cache);
ClassDB::bind_method(D_METHOD("set_anim_path", "path"), &Metin2AnimPlayer::set_anim_path);
ClassDB::bind_method(D_METHOD("get_anim_path"), &Metin2AnimPlayer::get_anim_path);
ClassDB::bind_method(D_METHOD("set_model_path", "path"), &Metin2AnimPlayer::set_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("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("get_time_scale"), &Metin2AnimPlayer::get_time_scale);
ClassDB::bind_method(D_METHOD("set_motion_loop_count", "count"), &Metin2AnimPlayer::set_motion_loop_count);
ClassDB::bind_method(D_METHOD("get_motion_loop_count"), &Metin2AnimPlayer::get_motion_loop_count);
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("get_time"), &Metin2AnimPlayer::get_time);
ClassDB::bind_method(D_METHOD("get_duration"), &Metin2AnimPlayer::get_duration);
ClassDB::bind_method(D_METHOD("reload"), &Metin2AnimPlayer::reload);
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("get_accumulation"), &Metin2AnimPlayer::get_accumulation);
ClassDB::bind_method(D_METHOD("get_events"), &Metin2AnimPlayer::get_events);
ClassDB::bind_method(D_METHOD("get_effect_bone_pose", "bone"), &Metin2AnimPlayer::get_effect_bone_pose);
ClassDB::bind_method(D_METHOD("get_loop_data"), &Metin2AnimPlayer::get_loop_data);
ClassDB::bind_method(D_METHOD("get_motion_data"), &Metin2AnimPlayer::get_motion_data);
ClassDB::bind_static_method("Metin2AnimPlayer",
D_METHOD("parse_combo_tables", "py_path"), &Metin2AnimPlayer::parse_combo_tables);
ClassDB::bind_static_method("Metin2AnimPlayer",
D_METHOD("make_combo_key", "motion_mode", "combo_type"), &Metin2AnimPlayer::make_combo_key);
// 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_SIGNAL(MethodInfo("motion_event_detailed", PropertyInfo(Variant::DICTIONARY, "event")));
ADD_PROPERTY(PropertyInfo(Variant::STRING, "anim_path", PROPERTY_HINT_GLOBAL_FILE, "*.gr2,*.msa"),
"set_anim_path", "get_anim_path");
ADD_PROPERTY(PropertyInfo(Variant::NODE_PATH, "model_path", PROPERTY_HINT_NODE_PATH_VALID_TYPES, "Node3D"),
"set_model_path", "get_model_path");
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"),
"set_time_scale", "get_time_scale");
ADD_PROPERTY(PropertyInfo(Variant::INT, "motion_loop_count"),
"set_motion_loop_count", "get_motion_loop_count");
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) {
if (p == anim_path) {
if (!playing) {
time = 0.0;
prev_time = 0.0;
playing = true;
}
return;
}
// SetMotionLoopCount belongs to the newly bound motion. Do not leak a
// previous skill's override into a normal walk/attack/action clip.
motion_loop_override = false;
motion_loop_count = 0;
// 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;
if (is_inside_tree()) {
reload();
}
}
void Metin2AnimPlayer::set_motion_loop_count(int count) {
if (count > 0) {
motion_loop_override = true;
motion_loop_count = count;
} else {
motion_loop_override = false;
motion_loop_count = msa_metadata.has_loop_data ? msa_metadata.motion_loop_count : 0;
}
}
void Metin2AnimPlayer::set_model_path(const NodePath &p) {
model_path = p;
}
void Metin2AnimPlayer::set_time(double 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()) {
apply_pose(time);
}
}
Metin2Model *Metin2AnimPlayer::resolve_model() const {
if (model_path.is_empty()) {
return nullptr;
}
Node *n = get_node_or_null(model_path);
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, const fmt::Msa &m) {
String low = spec.to_lower();
if (!low.ends_with(".msa")) {
return spec; // already a .gr2 path
}
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;
}
static Dictionary motion_event_dict(const fmt::MotionEvent &ev) {
Dictionary d;
d["type"] = ev.type;
d["start_time"] = ev.start_time;
d["duration_time"] = ev.duration_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]);
d["bone"] = String(ev.attaching_bone.c_str());
d["attaching"] = ev.attaching;
d["following"] = ev.following;
d["independent"] = ev.independent;
d["fishing_effect"] = ev.fishing_effect;
d["fly_file"] = String(ev.fly_file.c_str());
d["fly_bone"] = String(ev.fly_attaching_bone.c_str());
d["fly_attaching"] = ev.fly_attaching;
d["fly_pos"] = Vector3(ev.fly_position[0], ev.fly_position[1], ev.fly_position[2]);
d["power"] = ev.power;
d["affecting_range"] = ev.affecting_range;
d["enable_hit_process"] = ev.enable_hit_process;
d["attack_type"] = ev.attack_type;
d["hitting_type"] = ev.hitting_type;
d["stiffen_time"] = ev.stiffen_time;
d["invisible_time"] = ev.invisible_time;
d["external_force"] = ev.external_force;
d["hit_limit_count"] = ev.hit_limit_count;
d["collision_type"] = ev.collision_type;
Array spheres;
for (const fmt::MotionEvent::CollisionSphere &sphere : ev.collision_spheres) {
Dictionary sd;
sd["radius"] = sphere.radius;
sd["pos"] = Vector3(sphere.position[0], sphere.position[1], sphere.position[2]);
spheres.push_back(sd);
}
d["collision_spheres"] = spheres;
return d;
}
Dictionary Metin2AnimPlayer::get_effect_bone_pose(const String &bone) {
Dictionary result;
Metin2Model *model = resolve_model();
const gr2::Skeleton *sk = model ? model->gr2_skeleton() : nullptr;
if (!sk || !anim_file) return result;
for (size_t i = 0; i < sk->bones.size() && i < world_buf.size(); ++i) {
if (String(sk->bones[i].name.c_str()) == bone) {
result["transform"] = gr2_to_godot(world_buf[i]);
break;
}
}
return result;
}
void Metin2AnimPlayer::emit_motion_event(const fmt::MotionEvent &ev) {
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]));
emit_signal("motion_event_detailed", motion_event_dict(ev));
}
Array Metin2AnimPlayer::get_events() const {
Array a;
for (const fmt::MotionEvent &ev : events) {
a.push_back(motion_event_dict(ev));
}
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;
}
// CLIENT-GAP §3.3 / §3.5 — the currently-loaded .msa's ComboInputData / AttackingData.
// `next_combo` is the reference普攻节奏 (CRaceMotionData::GetNextComboTime); when the
// motion has no ComboInputData it falls back to MotionDuration * 0.9.
Dictionary Metin2AnimPlayer::get_motion_data() const {
const fmt::Msa &m = msa_metadata;
Dictionary d;
d["duration"] = duration > 0.0 ? duration : m.duration;
d["has_combo_input"] = m.has_combo_input;
d["pre_input_time"] = m.combo_pre_input_time;
d["direct_input_time"] = m.combo_direct_input_time;
d["input_limit_time"] = m.combo_input_limit_time;
d["link_time"] = m.combo_link_time;
const double dur = duration > 0.0 ? duration : m.duration;
d["next_combo"] = m.has_combo_input && m.combo_direct_input_time > 0.0
? (double)m.combo_direct_input_time
: dur * 0.9;
d["has_attacking_data"] = m.has_attacking_data;
d["attacking_type"] = m.attacking_type;
d["motion_type"] = m.motion_type;
d["hitting_type"] = m.hitting_type;
d["attack_start_time"] = m.attack_start_time;
d["attack_end_time"] = m.attack_end_time;
d["stiffen_time"] = m.stiffen_time;
d["invisible_time"] = m.invisible_time;
d["external_force"] = m.external_force;
d["hit_limit_count"] = m.hit_limit_count;
// CLIENT-GAP §3.5 修改 1 —— THitDataContainer 多命中窗(多段挥击 / 双持 = 多个窗)。
// [{start_time, end_time, bone, weapon_length, samples:[{time, last_pos:Vector3, pos:Vector3}]}]
Array windows;
for (const fmt::Msa::HitWindow &w : m.hit_windows) {
Dictionary wd;
wd["start_time"] = w.start_time;
wd["end_time"] = w.end_time;
wd["bone"] = String::utf8(w.bone_name.c_str());
wd["weapon_length"] = w.weapon_length;
Array samples;
for (const fmt::Msa::HitSample &s : w.samples) {
Dictionary sd;
sd["time"] = s.time;
sd["last_pos"] = Vector3(s.last_pos[0], s.last_pos[1], s.last_pos[2]);
sd["pos"] = Vector3(s.pos[0], s.pos[1], s.pos[2]);
samples.push_back(sd);
}
wd["samples"] = samples;
windows.push_back(wd);
}
d["hit_windows"] = windows;
return d;
}
int Metin2AnimPlayer::make_combo_key(int motion_mode, int combo_type) {
return (int)fmt::make_combo_key((uint16_t)motion_mode, (uint16_t)combo_type);
}
Dictionary Metin2AnimPlayer::parse_combo_tables(const String &py_path) {
Dictionary out;
fmt::PlayerComboTables tables;
std::string err;
if (!fmt::parse_player_combo_tables_file(
std::string(py_path.utf8().get_data()), tables, &err)) {
UtilityFunctions::push_warning(
String("Metin2AnimPlayer.parse_combo_tables: ") + String(err.c_str()));
return out;
}
for (int c = 0; c < fmt::COMBO_CLASS_COUNT; ++c) {
Dictionary cls;
for (const auto &kv : tables.klass(c).combos) {
PackedInt32Array segs;
segs.resize((int)kv.second.size());
for (int i = 0; i < (int)kv.second.size(); ++i) {
segs.set(i, (int)kv.second[i]);
}
cls[(int)kv.first] = segs;
}
out[c] = cls;
}
return out;
}
void Metin2AnimPlayer::_ready() {
set_process(true);
reload();
}
void Metin2AnimPlayer::reload() {
anim_file.reset();
duration = 0.0;
time = 0.0;
prev_time = 0.0;
next_event = 0;
accumulation = Vector3();
events.clear();
msa_metadata = fmt::Msa{};
last_info = "";
playing = true;
if (anim_path.is_empty()) {
return;
}
// .msa -> resolve the real motion .gr2 + pull accumulation / events.
const bool diagnostics = OS::get_singleton()->get_environment("MTGODOT_ANIM_DIAGNOSTICS") == "1";
String gr2_spec = anim_path;
if (anim_path.to_lower().ends_with(".msa")) {
fmt::Msa m;
std::string e;
if (cached_msa(anim_path, m, &e)) {
msa_metadata = m;
accumulation = Vector3(m.accumulation[0], m.accumulation[1], m.accumulation[2]);
events = m.events;
// Reuse the metadata just parsed; path resolution must not parse it again.
gr2_spec = resolve_anim_gr2(anim_path, m);
if (diagnostics) 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;
}
}
if (!motion_loop_override)
motion_loop_count = msa_metadata.has_loop_data ? msa_metadata.motion_loop_count : 0;
gr2::LoadError err;
auto f = cached_clip(gr2_spec, &err);
if (!f) {
UtilityFunctions::push_error(String("[Metin2AnimPlayer] anim load failed [") +
String(err.stage.c_str()) + "]: " + String(err.message.c_str()));
return;
}
const gr2::FileInfo &fi = f->file_info();
if (fi.animations.empty()) {
UtilityFunctions::push_error("[Metin2AnimPlayer] no animations in " + anim_path);
return;
}
anim_file = std::move(f);
const gr2::Animation &an = anim_file->file_info().animations[0];
// 40250 CRaceMotionData::GetMotionDuration() is sourced from the MSA
// MotionDuration field. GR2 contains sampled tracks, but its clip length
// is not the motion-data authority used by actor timing, LoopData and
// MotionEventData. Keep GR2 duration only for raw .gr2 paths or malformed
// MSA files without a positive MotionDuration.
duration = msa_metadata.duration > 0.0 ? msa_metadata.duration : an.duration;
// Diagnostic: how many anim tracks map to a model-skeleton bone by name?
if (Metin2Model *model = diagnostics ? resolve_model() : nullptr) {
if (const gr2::Skeleton *sk = model->gr2_skeleton()) {
int matched = 0;
godot::String unmatched;
for (const auto &tr : an.tracks) {
bool hit = false;
for (const auto &b : sk->bones) {
if (b.name == tr.bone_name) {
hit = true;
break;
}
}
if (hit) {
++matched;
} else if (unmatched.length() < 300) {
unmatched += String(tr.bone_name.c_str()) + " ";
}
}
if (diagnostics) {
UtilityFunctions::print(vformat(
"[Metin2AnimPlayer] track->bone match: %d/%d (skel bones=%d) unmatched: %s",
matched, (int)an.tracks.size(), (int)sk->bones.size(), unmatched));
}
}
}
last_info = vformat("anim '%s' dur=%.3f tracks=%d anims=%d",
String(an.name.c_str()), duration, (int)an.tracks.size(),
(int)anim_file->file_info().animations.size());
if (diagnostics) UtilityFunctions::print("[Metin2AnimPlayer] ", last_info);
apply_pose(0.0);
}
namespace {
// mul4x3 lives in gr2_bridge.h now (shared with the weapon-attach grip compose).
// Blend two local bone transforms: slerp rotation, lerp translation and the
// complete scale/shear basis (PARITY §2.9). Several warrior/sura armour bones
// depend on authored shear, so it must survive the transition as well as steady
// animation sampling.
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]));
const godot::Quaternion qa = ba.get_rotation_quaternion();
const godot::Quaternion qb = bb.get_rotation_quaternion();
godot::Quaternion q = qa.slerp(qb, w);
// Preserve Granny's complete scale/shear matrix. Keeping only get_scale()
// changes authored joints even at the endpoints and is especially visible in
// hands and shoulders. For B = R*S, extract S and interpolate all 3x3 terms.
const godot::Basis sa = godot::Basis(qa).inverse() * ba;
const godot::Basis sb = godot::Basis(qb).inverse() * bb;
godot::Basis ss;
for (int row = 0; row < 3; ++row) {
ss.rows[row] = sa.rows[row].lerp(sb.rows[row], w);
}
godot::Vector3 tt =
godot::Vector3(a[12], a[13], a[14]).lerp(godot::Vector3(b[12], b[13], b[14]), w);
const godot::Basis rb = godot::Basis(q) * ss;
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;
}
// Sample animation tracks into the model skeleton's LOCAL bone domain. Granny
// blends controls before BuildWorldPose; doing the crossfade after world-pose
// accumulation independently interpolates every joint endpoint and temporarily
// changes parent/child distances (visible as stretched or collapsed limbs when
// wait/walk/run switches).
void sample_local_pose(const gr2::Skeleton &sk, const gr2::Animation &an, float t,
std::vector<gr2::Mat4> &local) {
std::vector<gr2::Mat4> track_local;
an.sample_local(t, track_local);
local.resize(sk.bones.size());
for (size_t i = 0; i < sk.bones.size(); ++i) {
local[i] = sk.bones[i].local_transform;
}
for (size_t ti = 0; ti < an.tracks.size() && ti < track_local.size(); ++ti) {
for (size_t bi = 0; bi < sk.bones.size(); ++bi) {
if (sk.bones[bi].name == an.tracks[ti].bone_name) {
local[bi] = track_local[ti];
break;
}
}
}
}
void accumulate_local_pose(const gr2::Skeleton &sk, const std::vector<gr2::Mat4> &local,
const gr2::Mat4 &root_offset, std::vector<gr2::Mat4> &world,
std::vector<gr2::Mat4> &skin) {
static const gr2::Mat4 I{ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 };
const size_t n = sk.bones.size();
world.assign(n, I);
skin.assign(n, I);
for (size_t i = 0; i < n; ++i) {
const int parent = sk.bones[i].parent;
const gr2::Mat4 &parent_world =
(parent < 0 || static_cast<size_t>(parent) >= i) ? root_offset : world[parent];
world[i] = mul4x3(i < local.size() ? local[i] : sk.bones[i].local_transform,
parent_world);
skin[i] = mul4x3(sk.bones[i].inverse_world, world[i]);
}
}
} // namespace
void Metin2AnimPlayer::apply_pose(double t) {
if (!anim_file) {
return;
}
Metin2Model *model = resolve_model();
if (!model) {
return;
}
const gr2::Skeleton *sk = model->gr2_skeleton();
Skeleton3D *skel = model->skeleton_node();
if (!sk || !skel) {
return;
}
const gr2::Animation &an = anim_file->file_info().animations[0];
double tt = t;
if (duration > 0.0 && loop) {
tt = std::fmod(t, duration);
if (tt < 0.0) {
tt += duration;
}
} else if (duration > 0.0) {
tt = std::fmax(0.0, std::fmin(t, duration));
}
// Offset4x4 = identity, NOT the model's exported InitialPlacement.
//
// gr2::sample_pose defaults to sk.initial_placement because that is what the
// oracle passes (GrannyGetModelInitialPlacement4x4) and what the bind pose /
// InverseWorldTransform pair is self-consistent with. Metin2's animation .gr2
// carries the root bone's pelvis height inside its own track, so composing it
// onto InitialPlacement counts that placement twice and lifts the whole actor
// by one pelvis height (warrior 99.4 cm, assassin 92.6, sura 103.7, shaman
// 95.5 — exactly each model's IP.z). That was the "人物悬空" report.
// Measured with tools/rendering foot probe: with identity the lowest skinned
// vertex sits within a few mm of z=0 for wait/walk on all four classes, and
// only leaves the ground during run's airborne frames.
static const gr2::Mat4 kNoOffset{ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 };
bool pose_ready = false;
// Crossfade in LOCAL bone space, then accumulate the hierarchy and rebuild
// skin_buf = invWorld · world. Blending world transforms made bone endpoints
// take independent straight-line paths, shortening limbs around the midpoint.
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()) {
sample_local_pose(*sk, an, (float)tt, local_buf);
sample_local_pose(*sk, pfi.animations[0], (float)prev_anim_time, prev_local_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(local_buf.size(), prev_local_buf.size());
blend_local_buf.resize(local_buf.size());
for (size_t i = 0; i < n; ++i) {
blend_local_buf[i] = blend_trs(prev_local_buf[i], local_buf[i], (float)w);
}
for (size_t i = n; i < local_buf.size(); ++i) {
blend_local_buf[i] = local_buf[i];
}
accumulate_local_pose(*sk, blend_local_buf, kNoOffset, world_buf, skin_buf);
pose_ready = true;
}
}
if (!pose_ready) {
gr2::sample_pose(*sk, an, (float)tt, world_buf, skin_buf, &kNoOffset);
}
// 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).
static const String kZero("0");
static const String kEnvName("MTGODOT_GPUSKIN");
String env = godot::OS::get_singleton()->get_environment(kEnvName);
// Default to GPU skinning (true) unless explicitly set to "0"
if (env != kZero) {
model->enable_gpu_skin(true);
model->gpu_skin(skin_buf);
} else {
model->enable_cpu_skin(true);
model->cpu_skin(skin_buf);
}
// Rigid weapon follows equip_right_hand's animated world transform (PARITY §2.1).
model->update_weapon_pose(world_buf);
}
void Metin2AnimPlayer::_process(double delta) {
if (!playing || !anim_file) {
return;
}
if (prev_anim_file) {
blend_elapsed += delta; // real seconds, independent of time_scale
if (blend_elapsed >= blend_time) {
prev_anim_file.reset();
}
}
double next = time + delta * time_scale;
// 40250 does not loop the whole clip. For a motion with LoopData it keeps
// the lead-in and tail, and only rewinds LoopStartTime..LoopEndTime while
// the actor's remaining loop count is > 1 (or -1 for infinite looping).
// The legacy actor resets to LoopStartTime at the first frame after the
// loop end, so the frame remainder is intentionally discarded here too.
const bool has_segment_loop = !loop && msa_metadata.has_loop_data &&
motion_loop_count != 0 &&
(motion_loop_count == -1 || motion_loop_count > 1) &&
msa_metadata.loop_end_time > msa_metadata.loop_start_time &&
msa_metadata.loop_end_time <= duration;
if (has_segment_loop && next > msa_metadata.loop_end_time) {
dispatch_events(time, msa_metadata.loop_end_time);
time = msa_metadata.loop_start_time;
if (motion_loop_count > 0)
--motion_loop_count;
apply_pose(time);
// ActorInstance resets dwcurFrame to LoopStartTime and processes
// that frame immediately. Without an inclusive point dispatch,
// events authored exactly at the loop boundary never fire.
dispatch_events(time, time, true);
prev_time = time;
return;
}
if (!loop && duration > 0.0 && next >= duration) {
next = duration;
}
time = next;
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, bool include_from) {
if (events.empty() || duration <= 0.0) {
return;
}
const double epsilon = 1e-9;
if (to < from - epsilon) {
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) {
const bool at_inclusive_start = include_from &&
std::fabs(double(ev.start_time) - a) <= epsilon;
// The first 40250 motion frame is frame 0, not frame 1. The
// normal interval remains half-open on the left so a regular
// frame cannot replay an event on every tick.
const bool initial_zero = !include_from && from <= epsilon &&
to > from + epsilon && std::fabs(double(ev.start_time)) <= epsilon;
if (at_inclusive_start || initial_zero ||
(a < ev.start_time && ev.start_time <= b)) {
emit_motion_event(ev);
}
}
return;
}
// Walk each clip segment independently. The old single-wrap expression
// could emit at most one copy of an event when time_scale/delta crossed
// two or more full durations; ActorInstance processes every frame and
// therefore reaches every crossed MotionEventData entry.
double cursor = from;
double remaining = to - from;
if (remaining <= epsilon) {
if (!include_from) {
return;
}
double point = std::fmod(cursor, duration);
if (point < 0) point += duration;
for (const fmt::MotionEvent &ev : events) {
if (std::fabs(double(ev.start_time) - point) <= epsilon) {
emit_motion_event(ev);
}
}
return;
}
while (remaining > epsilon) {
double a = std::fmod(cursor, duration);
if (a < 0) a += duration;
double segment = std::fmin(remaining, duration - a);
if (segment <= epsilon) {
cursor += duration;
continue;
}
double b = a + segment;
const bool include_segment_start = a <= epsilon;
for (const fmt::MotionEvent &ev : events) {
const bool at_segment_start = include_segment_start &&
std::fabs(double(ev.start_time) - a) <= epsilon;
const bool crossed = a < ev.start_time && ev.start_time <= b;
if (at_segment_start || crossed) {
emit_motion_event(ev);
}
}
cursor += segment;
remaining -= segment;
}
}
String Metin2AnimPlayer::selfcheck(int samples) {
if (!anim_file) {
return "no anim loaded";
}
Metin2Model *model = resolve_model();
const gr2::Skeleton *sk = model ? model->gr2_skeleton() : nullptr;
if (!sk) {
return "no model/skeleton";
}
const auto &anims = anim_file->file_info().animations;
int total = 0;
int nan_hits = 0;
std::vector<gr2::Mat4> w, s;
for (const auto &an : anims) {
double dur = an.duration > 0.0 ? an.duration : 1.0;
for (int k = 0; k <= samples; ++k) {
float tt = (float)(dur * k / samples);
gr2::sample_pose(*sk, an, tt, w, s);
for (const auto &mtx : w) {
for (float v : mtx) {
++total;
if (std::isnan(v) || std::isinf(v)) {
++nan_hits;
}
}
}
}
}
return vformat("selfcheck: anims=%d samples/anim=%d floats=%d NaN/Inf=%d",
(int)anims.size(), samples + 1, total, nan_hits);
}
} // namespace mtgodot
-140
View File
@@ -1,140 +0,0 @@
#pragma once
#include <godot_cpp/classes/node3d.hpp>
#include <godot_cpp/variant/dictionary.hpp>
#include <godot_cpp/variant/node_path.hpp>
#include <godot_cpp/variant/string.hpp>
#include <godot_cpp/variant/vector3.hpp>
#include <gr2/gr2.h>
#include <msa.h>
#include <optional>
#include <memory>
#include <vector>
namespace mtgodot {
class Metin2Model;
// Drives a Metin2Model each frame by sampling a (possibly separate) animation
// .gr2 with libgr2, then skinning it with the resulting matrices:
//
// sample_pose(model.skeleton, anim, t) -> world[i], skin[i]
// skin[i] = inverse_world[i] * world[i] (gr2 deformer matrix per bone)
//
// The skinning itself is done in Metin2Model (NOT via Skeleton3D — Godot's
// 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 {
GDCLASS(Metin2AnimPlayer, godot::Node3D)
public:
Metin2AnimPlayer();
~Metin2AnimPlayer() override;
void _ready() override;
void _process(double delta) override;
void set_anim_path(const godot::String &p);
godot::String get_anim_path() const { return anim_path; }
void set_model_path(const godot::NodePath &p);
godot::NodePath get_model_path() const { return model_path; }
void set_playing(bool v) { playing = v; }
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; }
double get_time_scale() const { return time_scale; }
// 40250 CActorInstance::SetMotionLoopCount. A positive value overrides the
// current .msa LoopData count; zero restores the resource-defined count.
void set_motion_loop_count(int count);
int get_motion_loop_count() const { return motion_loop_count; }
// 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);
double get_time() const { return time; }
double get_duration() const { return duration; }
void reload();
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; // detailed MotionEvent dictionaries, including type-specific payloads
// Full affine pose in model GR2 coordinates, shared by CPU/GPU and blending.
godot::Dictionary get_effect_bone_pose(const godot::String &bone);
godot::Dictionary get_loop_data() const;
godot::Dictionary get_motion_data() const; // {duration,next_combo,attack_start_time,...}
static godot::Dictionary get_clip_cache_stats();
static void clear_clip_cache();
// CLIENT-GAP §3.5 — parse the PC combo tables out of playersettingmodule.py
// (chrmgr.ReserveComboAttackNew / RegisterComboAttackNew → CRaceData). Returns
// { class_idx:int -> { combo_key:int -> PackedInt32Array(段号 14..21) } }
// with class_idx 0=warrior 1=assassin 2=sura 3=shaman and
// combo_key = (motion_mode << 16) | combo_type. Empty Dictionary on failure.
static godot::Dictionary parse_combo_tables(const godot::String &py_path);
// (motion_mode << 16) | combo_type — mirrors RaceData.h MAKE_COMBO_KEY.
static int make_combo_key(int motion_mode, int combo_type);
// NaN-scan every animation in anim_path across [0,dur]; returns a report string.
godot::String selfcheck(int samples = 24);
protected:
static void _bind_methods();
private:
void emit_motion_event(const fmt::MotionEvent &ev);
godot::String anim_path;
godot::NodePath model_path;
bool playing = true;
bool loop = true; // whole-clip playback mode; independent from .msa LoopData
double time_scale = 1.0;
int motion_loop_count = 0;
bool motion_loop_override = false;
double time = 0.0;
double duration = 0.0;
godot::String last_info;
std::shared_ptr<const gr2::File> anim_file;
std::vector<gr2::Mat4> world_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::shared_ptr<const 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;
// Crossfades operate before hierarchy accumulation so parent/child lengths
// remain stable while switching locomotion clips.
std::vector<gr2::Mat4> local_buf, prev_local_buf, blend_local_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;
void apply_pose(double t);
void dispatch_events(double from, double to, bool include_from = false);
// .msa path -> real motion .gr2 path; passes plain .gr2 paths through.
static godot::String resolve_anim_gr2(const godot::String &spec, const fmt::Msa &metadata);
};
} // namespace mtgodot
File diff suppressed because it is too large Load Diff
-277
View File
@@ -1,277 +0,0 @@
#pragma once
#include <godot_cpp/classes/array_mesh.hpp>
#include <godot_cpp/classes/image.hpp>
#include <godot_cpp/classes/image_texture.hpp>
#include <godot_cpp/classes/node3d.hpp>
#include <godot_cpp/classes/ref.hpp>
#include <godot_cpp/templates/hash_map.hpp>
#include <godot_cpp/variant/array.hpp>
#include <godot_cpp/variant/aabb.hpp>
#include <godot_cpp/variant/dictionary.hpp>
#include <godot_cpp/variant/packed_string_array.hpp>
#include <godot_cpp/variant/string.hpp>
#include <gr2/gr2.h>
#include "gr2_bridge.h"
#include <memory>
#include <optional>
#include <vector>
namespace godot {
class Skeleton3D;
class MeshInstance3D;
} // namespace godot
namespace mtgodot {
// Loads a Metin2 .gr2 and builds a Godot subtree:
// Metin2Model (transform = Z-up/cm -> Y-up/m conversion)
// └─ Skeleton3D (rest pose from gr2 bones)
// └─ MeshInstance3D (ArrayMesh, one surface per gr2 mesh; skinned)
//
// M1: static bind-pose render. M2's Metin2AnimPlayer drives the Skeleton3D.
class Metin2Model : public godot::Node3D {
GDCLASS(Metin2Model, godot::Node3D)
public:
Metin2Model();
~Metin2Model() override;
void _ready() override;
void _process(double delta) override;
// --- 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);
godot::String get_gr2_path() const { return gr2_path; }
void set_texture_dir(const godot::String &p);
godot::String get_texture_dir() const { return texture_dir; }
void set_unit_scale(double s);
double get_unit_scale() const { return unit_scale; }
void set_flip_z(bool v);
bool get_flip_z() const { return flip_z; }
void set_flip_winding(bool v);
bool get_flip_winding() const { return flip_winding; }
void set_material_mode(const godot::String &m);
godot::String get_material_mode() const { return material_mode; }
void set_use_gr2_materials(bool v);
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).
void set_surface_texture(int surface, const godot::String &path);
// Reference SetMaterialImagePointer(part, SourceSkin, TargetSkin): replace
// every GR2 material whose texture basename matches source, rather than
// assuming that an armour skin always belongs to surface 0.
void set_skin_texture(const godot::String &source, const godot::String &target);
void clear_skin_textures();
// 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.
void reload();
// One-line summary (bones / meshes / verts / bounds).
godot::String get_info() const;
// Deformed mesh bounds in this node's local GR2 coordinate frame. Unlike
// MeshInstance3D::get_aabb(), this follows the GPU shader pose as well.
godot::AABB get_visual_aabb();
godot::Dictionary get_fly_target_bounds();
// Parent-space Y adjustment that places the bind-pose model's lowest point
// on the actor origin. GR2 is Z-up/cm; this is converted to Godot Y/metres.
double get_ground_offset() const { return ground_offset; }
// --- C++ accessors for Metin2AnimPlayer (same extension) ---
const gr2::Skeleton *gr2_skeleton() const;
godot::Skeleton3D *skeleton_node() const { return skel; }
const gr2::FileInfo *gr2_fileinfo() const;
// Latest full-affine deformer matrices. Initialized to bind pose on reload
// and refreshed by either skinning path; attachments can share this pose.
const std::vector<gr2::Mat4> &current_skin_matrices() const { return current_skin; }
// CPU linear-blend skinning: rewrite ArrayMesh vertex regions from the
// per-bone deformer matrices (gr2::sample_pose's `skin` output). Debug/
// reference path; bypasses Godot's Skeleton3D skinning entirely.
void cpu_skin(const std::vector<gr2::Mat4> &skin);
bool has_cpu_skin_mesh() const { return cpu_mesh.is_valid(); }
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').
godot::String probe_gr2(const godot::String &path) const;
static void clear_texture_cache();
protected:
static void _bind_methods();
private:
godot::String gr2_path;
godot::String texture_dir;
double unit_scale = 0.01;
bool flip_z = false;
bool flip_winding = false;
godot::String material_mode = "metin2"; // "metin2" (ShaderMaterial) | "standard"
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::Dictionary skin_tex_override; // lowercase source basename -> target path
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 _refresh_hair_mesh();
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 root rigid-mesh composite (invWorld · local),
// folded in before the parent hand matrix exactly as Granny does for an attached
// model. This may contain a large translation which cancels an authored vertex
// offset (notably 03150), so it must not be clamped.
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::vector<gr2::MaterialInfo> materials;
godot::Skeleton3D *skel = nullptr;
godot::MeshInstance3D *mi = nullptr;
godot::String last_info;
double ground_offset = 0.0;
// §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
bool gpu_skin_active = false;
bool gpu_visual_bounds_ready = false;
bool have_visual_bounds = false;
godot::AABB visual_bounds;
std::vector<gr2::Mat4> current_world_pose;
godot::AABB fly_target_bounds;
bool fly_target_bounds_valid = 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 _apply_materials();
bool _update_skinned_bounds(const std::vector<gr2::Mat4> &skin);
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> _resolve_texture(const godot::String &dir,
const godot::String &stem, const godot::String &surface_name,
const godot::PackedStringArray &dds_files);
};
} // namespace mtgodot
File diff suppressed because it is too large Load Diff
-198
View File
@@ -1,198 +0,0 @@
#pragma once
#include <godot_cpp/classes/image.hpp>
#include <godot_cpp/classes/node3d.hpp>
#include <godot_cpp/classes/ref.hpp>
#include <godot_cpp/variant/array.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 <cstdint>
#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:
struct HeightTriangle {
godot::Vector3 v0;
godot::Vector3 v1;
godot::Vector3 v2;
float min_x = 0.0f, max_x = 0.0f;
float min_z = 0.0f, max_z = 0.0f;
godot::Vector3 normal;
float d = 0.0f;
};
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);
void set_initial_focus_cm(godot::Vector2 position) { initial_focus_cm = position; }
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; }
void set_collision_enabled(bool v) { collision_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; }
// ClientVS22 InstanceBaseMotion::GetFishingRot 等价:在角色前方 600cm
// 按当前 heading ±(0..180°,步长 10°) 扫描 ATTRIBUTE_WATER。
// 返回选中的服务端 heading;没有水面或目标区块未加载时返回 -1。
double get_fishing_rotation(double gx_m, double gz_m, double heading_deg) const;
bool can_fishing_position(double gx_m, double gz_m, double heading_deg) const;
godot::Dictionary get_load_report() const;
// Area ambience sources from AreaAmbienceData + .pra properties. Audio owns
// playback; the world only exposes the reference source records.
godot::Array get_ambience_sources() 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;
godot::Vector2 initial_focus_cm;
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 native_trees_placed = 0, proxy_trees_placed = 0;
int objects_mdatr_built = 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<HeightTriangle> height_triangles;
};
struct AmbienceSource {
int tile_x = 0, tile_y = 0;
int object_index = 0;
godot::Vector3 position;
int range_cm = 0;
float max_volume_area_percentage = 0.0f;
float play_interval = 0.0f;
float play_interval_variation = 0.0f;
godot::String play_type;
std::vector<std::string> sounds;
};
std::vector<Chunk> chunks;
std::vector<std::pair<int, int>> stream_queue; // 待建区块
std::vector<AmbienceSource> ambience_sources;
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,
std::vector<HeightTriangle> &out_heights);
void place_chunk_ambience(int tx, int ty);
void unload_chunk(int idx);
void stream_update();
const Chunk *chunk_at(int tx, int ty) const;
};
} // namespace mtgodot
File diff suppressed because it is too large Load Diff
-433
View File
@@ -1,433 +0,0 @@
#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/core/object_id.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;
class EntityStore;
namespace classic {
class ClassicSession;
class ClassicMarkClient;
}
} // namespace mtnet
namespace mtgodot {
class M2Client : public godot::Node {
GDCLASS(M2Client, godot::Node)
public:
M2Client();
~M2Client() override;
void _process(double delta) override;
// 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);
// LoginPhase retry equivalent of LoginWindow.Connect. The implementation
// reuses a pending classic session and replaces only its auth transport.
void retry_login(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();
// Share the already-loaded item_proto with native action gates. The object
// is held by instance id so unloading a scene cannot leave a dangling ptr.
void set_item_proto(godot::Object *proto);
// Resolve the same item use/drop sound CPythonItem selects from item_proto.
// This small public seam keeps the resource-side branch directly testable.
godot::String item_action_sound_for_item(int vnum, bool drop) const;
// pick a slot from the char_list payload (its "index" field).
bool select_character(int index);
// Enter a character through the stock two-connection direct-enter flow.
bool enter_game(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;
// CG_EMPIRE: choose one of the three empires when the account has no empire.
// Both network backends expose the same intent for the character-select UI.
bool select_empire(int empire);
// 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);
// Reference client SendEmoticon: send the registered chat token and let the
// presentation layer play the matching local effect.
bool send_emoticon(int index);
bool whisper(const godot::String &to, const godot::String &text);
// cast: CG_MOVE with func = FUNC_SKILL(0x80) | (motion_idx & 0x7F),
// arg = loop count low nibble | moving-skill bit (1 << 4).
bool cast_skill(int motion_idx, double rot_deg, int x, int y, int arg = 0);
// Real skill intent. The non-classic backend may include its primary target
// packet here; the classic 40250 caller sends primary fly targeting from the
// bow event and uses this method for CG_USE_SKILL itself.
bool use_skill(int skill_id, int target_vid);
// Ranged animation events and area-target selection use separate packets.
bool shoot(int skill_id);
// Primary bow/skill fly target: CG_FLY_TARGETING. Area skills use the
// separate CG_ADD_FLY_TARGETING path below.
bool fly_targeting(int target_vid, int x, int y);
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);
bool friend_answer(const godot::String &name, bool accept);
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}]
bool safebox_password(const godot::String &password);
// 40250 的仓库关闭/改密走 chat 命令(cmd.cpp do_safebox_close /
// do_safebox_change_password,均 GM_PLAYER),与 safebox_password 同通道。
bool safebox_change_password(const godot::String &old_password,
const godot::String &new_password);
bool safebox_close();
// --- 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);
bool mall_password(const godot::String &password);
bool mall_close(); // "/mall_close"(cmd.cpp do_mall_close)
// --- 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);
bool cube_open();
bool cube_close();
bool cube_list();
bool cube_add_item(int cube_index, int inventory_index);
bool cube_delete_item(int cube_index);
// --- 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_charge_gsp(int amount);
// 40250 服务器把这两个子头禁用了(input_main.cpp 直接 return,死代码在下方),
// 但原版 ClientVS22 UI 仍照发(uiguild.py OnDeposit/OnWithdraw)——保持 1:1。
bool guild_deposit_money(int amount);
bool guild_withdraw_money(int amount);
bool guild_change_grade_name(int grade, const godot::String &name);
bool guild_change_grade_authority(int grade, int authority);
bool guild_change_member_grade(int pid, int grade);
bool guild_change_member_general(int pid, bool enabled);
bool guild_post_comment(const godot::String &text);
bool guild_delete_comment(int comment_id);
bool guild_refresh_comments();
bool guild_answer_invite(int guild_id, bool accept);
bool guild_answer_make(const godot::String &name);
godot::Array get_guild_comments() const; // [{id, name, content}]
// --- 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}]
bool is_observer_mode() const;
int get_observer_count() const;
bool has_mobile_flag() const;
bool combo_skill_enabled() const;
godot::Dictionary get_stamina_state() const; // {consuming, per_sec, current}
// 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}]
godot::Dictionary get_duel() const; // {active, cannot_attack, opponents}
// --- networked world snapshot (positions already Godot-space, metres) ---
godot::Dictionary get_entity(int vid) const;
// Root-motion speeds (cm/s at movSpd 100) of a remote actor's WALK / RUN loop,
// measured by the view from its .msa; drives the reference-style remote walk.
void set_entity_motion_speed(int vid, double walk_cm_s, double run_cm_s);
// Presentation-owned CInstanceBase network-state gates. These are updated
// when a remote skill/emotion starts or reaches its motion tail.
void set_entity_network_state_gate(int vid, bool skill_active, bool skill_can_cancel,
bool acting_emotion, bool tcp_state_enabled);
// Presentation-owned knockdown latch used by TEMP_Push's
// DAMAGE_FLYING -> STAND_UP motion chain.
void set_entity_knockdown(int vid, bool on);
godot::Array get_entities() const;
int get_main_vid() const;
int get_main_pid() 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 only until the classic session has sent its login packet. The
// reference client clears its network password at that boundary; classic
// reconnects then use last_login_key or require a fresh explicit login.
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;
// MT_PROTOCOL=classic (40250): replaces auth+game for the flows it supports
// (connect/login/select/enter-game/world/intents). Null otherwise.
std::unique_ptr<mtnet::classic::ClassicSession> classic_sess;
bool classic_list_emitted = false;
// A peer-close or replacement-connect failure has already returned
// ClassicSession to LoginPhase, but the host must still allow the next
// reconnect request to replace the old socket instead of treating the
// transient GameLogin stage as in-flight.
bool classic_remote_disconnect_pending = false;
// Keep the auth Offline owner retryable across pumps after
// AccountConnector::OnConnectFailure; a later frame must not synthesize a
// terminal Failed state for the same transport error.
bool classic_auth_connect_failure_pending = false;
// AccountConnector::OnRemoteDisconnect returns to Offline without invoking
// OnLoginFailure/OnConnectFailure; keep that owner non-terminal across pumps.
bool classic_auth_remote_disconnect_pending = false;
int classic_last_stage = -1;
int classic_last_empire = -1;
void pump_classic();
void bind_classic_loading_reset();
// the EntityStore of whichever backend is active (classic_sess or game).
const mtnet::EntityStore *active_world() const;
std::unique_ptr<mtnet::MarkClient> mark;
std::unique_ptr<mtnet::classic::ClassicMarkClient> classic_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();
void pump_classic_mark();
int last_auth_state = -1;
int last_game_state = -1;
int last_game_phase = -1;
int last_game_empire = -1;
bool char_list_emitted = false;
uint32_t selected_pid = 0;
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;
bool safebox_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();
bool item_action_allowed(bool equipment_touched) const;
bool is_equip_item_in_slot(int window, int cell) const;
bool is_equipment_cell(int window, int cell) const;
bool item_proto_known(uint32_t vnum) const;
bool item_proto_type(uint32_t vnum, uint8_t &type) const;
void emit_item_action_sound(int window, int cell, bool drop);
void apply_item_proto_validator();
godot::ObjectID item_proto_id;
bool item_proto_ready = false;
mtnet::EntityStore *item_validator_world = nullptr;
bool item_validator_ready = false;
bool local_main_attacking = false;
};
} // namespace mtgodot
+2 -28
View File
@@ -7,29 +7,18 @@
// with the Chinese build using GB2312-compatible bytes) and fills fixed C // with the Chinese build using GB2312-compatible bytes) and fills fixed C
// buffers by byte capacity, never by UTF-8 character count. // buffers by byte capacity, never by UTF-8 character count.
// //
// This project carries text as UTF-8 end to end (GDScript String -> // This project carries text as UTF-8 in std::string. The public helpers below are the one boundary where fixed
// std::string). The public helpers below are the one boundary where fixed
// 40250 fields are converted to/from the wire code page. Map names, quest // 40250 fields are converted to/from the wire code page. Map names, quest
// titles, system text and other dynamic text must use their own packet capacity // titles, system text and other dynamic text must use their own packet capacity
// and must not be validated as CHARACTER_NAME_MAX_LEN fields. // and must not be validated as CHARACTER_NAME_MAX_LEN fields.
// //
// mtnet (ClassicParser / ClassicSession) links **without** godot-cpp, so the // ClassicParser and ClassicSession use the std::string_view API below.
// std::string_view core below is the one those call sites use. The
// godot::String overloads are compiled only where <godot_cpp/...> is on the
// include path (the M2Client / GDScript boundary).
#include <cstddef> #include <cstddef>
#include <cstdint> #include <cstdint>
#include <string> #include <string>
#include <string_view> #include <string_view>
#if defined(__has_include)
# if __has_include(<godot_cpp/variant/string.hpp>)
# include <godot_cpp/variant/char_string.hpp>
# include <godot_cpp/variant/string.hpp>
# define MT_TEXT_CODEC_HAS_GODOT 1
# endif
#endif
namespace mtnet { namespace mtnet {
@@ -83,19 +72,4 @@ inline bool protocol_text_fits(std::string_view utf8, size_t cap, bool legacy_co
return legacy_codepage ? wire_text_fits(utf8, cap) : utf8.size() <= cap; return legacy_codepage ? wire_text_fits(utf8, cap) : utf8.size() <= cap;
} }
#ifdef MT_TEXT_CODEC_HAS_GODOT
// §1.10 named API — the GDScript / M2Client boundary.
inline std::string to_wire(const godot::String &s, size_t cap) {
const godot::CharString u = s.utf8();
const char *data = u.get_data();
return to_wire(std::string_view(data ? data : "", data ? static_cast<size_t>(u.length()) : 0),
cap);
}
inline godot::String from_wire(const char *bytes, size_t cap) {
const std::string s = from_wire_str(bytes, cap);
return godot::String::utf8(s.c_str(), static_cast<int64_t>(s.size()));
}
#endif
} // namespace mtnet } // namespace mtnet
-35
View File
@@ -1,35 +0,0 @@
#include "pack40250_node.h"
#include <godot_cpp/core/class_db.hpp>
#include "asset_io.h"
#include "platform/PackBackend.h"
using namespace godot;
namespace mtgodot {
bool Metin2Pack::initialize(const String &client_dir) {
return mtpack40250::initialize(client_dir.utf8().get_data());
}
bool Metin2Pack::is_ready() {
return mtpack40250::ready();
}
bool Metin2Pack::exists(const String &name) {
return file_exists(String(PACK_SCHEME) + name);
}
PackedByteArray Metin2Pack::get_bytes(const String &name) {
return read_file(String(PACK_SCHEME) + name);
}
void Metin2Pack::_bind_methods() {
ClassDB::bind_static_method("Metin2Pack", D_METHOD("initialize", "client_dir"), &Metin2Pack::initialize);
ClassDB::bind_static_method("Metin2Pack", D_METHOD("is_ready"), &Metin2Pack::is_ready);
ClassDB::bind_static_method("Metin2Pack", D_METHOD("exists", "name"), &Metin2Pack::exists);
ClassDB::bind_static_method("Metin2Pack", D_METHOD("get_bytes", "name"), &Metin2Pack::get_bytes);
}
} // namespace mtgodot
-28
View File
@@ -1,28 +0,0 @@
#pragma once
// Metin2Pack — GDScript access to the 40250 packs (platform/PackBackend.h).
//
// Metin2Pack.initialize(AssetRoot.client_path()) # registers <Client>/pack/Index once
// var b := Metin2Pack.get_bytes("locale/en/item_proto")
//
// Every native reader that takes a path accepts "pack://<40250 path>" through asset_io.
#include <godot_cpp/classes/object.hpp>
#include <godot_cpp/variant/packed_byte_array.hpp>
#include <godot_cpp/variant/string.hpp>
namespace mtgodot {
class Metin2Pack : public godot::Object {
GDCLASS(Metin2Pack, godot::Object)
public:
static bool initialize(const godot::String &client_dir);
static bool is_ready();
static bool exists(const godot::String &name);
static godot::PackedByteArray get_bytes(const godot::String &name);
protected:
static void _bind_methods();
};
} // namespace mtgodot
@@ -8,6 +8,7 @@
#include "EterBase/Timer.h" #include "EterBase/Timer.h"
#include "EterLib/Util.h" #include "EterLib/Util.h"
#include "EterLib/GrpBase.h" #include "EterLib/GrpBase.h"
#include "EterLib/GrpImage.h"
#include "EterLib/Camera.h" #include "EterLib/Camera.h"
#include "../EterLib/HostCursor.h" #include "../EterLib/HostCursor.h"
#include "UserInterface/PythonNetworkStream.h" #include "UserInterface/PythonNetworkStream.h"
@@ -408,6 +409,44 @@ void SetAttackKey(bool pressed)
CPythonPlayer::Instance().SetAttackKeyState(pressed); CPythonPlayer::Instance().SetAttackKeyState(pressed);
} }
void UseLocalQuickSlot(int localSlotIndex)
{
if (!g_game_singletons || localSlotIndex < 0 || localSlotIndex >= 8) return;
CPythonPlayer::Instance().RequestUseLocalQuickSlot(static_cast<DWORD>(localSlotIndex));
}
bool TryAssignAttachedObjectToLocalQuickSlot(int localSlotIndex)
{
if (!g_game_singletons || localSlotIndex < 0 || localSlotIndex >= 8) return false;
std::string attached;
std::string error;
if (!Evaluate("__import__('mouseModule').mouseController.isAttached()", &attached, &error) ||
(attached != "True" && attached != "1"))
return false;
const std::string command =
"_stream.curPhaseWindow.interface.wndTaskBar.AddQuickSlot(" + std::to_string(localSlotIndex) + ")";
return RunLine(command.c_str(), &error);
}
std::string LocalQuickSlotSkillIcon(int localSlotIndex)
{
if (!g_game_singletons || localSlotIndex < 0 || localSlotIndex >= 8) return {};
DWORD type = SLOT_TYPE_NONE;
DWORD position = 0;
CPythonPlayer& player = CPythonPlayer::Instance();
player.GetLocalQuickSlotData(static_cast<DWORD>(localSlotIndex), &type, &position);
if (type != SLOT_TYPE_SKILL) return {};
CPythonSkill::TSkillData* skillData = nullptr;
if (!CPythonSkill::Instance().GetSkillData(player.GetSkillIndex(position), &skillData) || !skillData)
return {};
const int grade = std::clamp(player.GetSkillGrade(position), 0, CPythonSkill::SKILL_GRADE_COUNT - 1);
CGraphicImage* image = skillData->GradeData[grade].pImage;
if (!image) image = skillData->pImage;
return image ? image->GetFileName() : std::string{};
}
void CameraBeginDrag(int x, int y) void CameraBeginDrag(int x, int y)
{ {
CCameraManager& rkCmrMgr = CCameraManager::Instance(); CCameraManager& rkCmrMgr = CCameraManager::Instance();
@@ -66,6 +66,9 @@ void UIKey(int key, bool pressed);
// Mobile touch & joystick controls // Mobile touch & joystick controls
void SetMoveDirection(float angleDeg, bool moving); void SetMoveDirection(float angleDeg, bool moving);
void SetAttackKey(bool pressed); void SetAttackKey(bool pressed);
void UseLocalQuickSlot(int localSlotIndex);
bool TryAssignAttachedObjectToLocalQuickSlot(int localSlotIndex);
std::string LocalQuickSlotSkillIcon(int localSlotIndex);
void CameraBeginDrag(int x, int y); void CameraBeginDrag(int x, int y);
void CameraDrag(int x, int y); void CameraDrag(int x, int y);
void CameraEndDrag(); void CameraEndDrag();
+3 -6
View File
@@ -1,6 +1,5 @@
# port_logic — mirror of the 40250 client logic (extension/src/port/<Lib>/<File>.{h,cpp}). # port_logic — mirror of the 40250 client logic (extension/src/port/<Lib>/<File>.{h,cpp}).
# No godot-cpp dependency: libmtgodot links it, and it also builds on its own (top-level # This library and port_platform are built directly for the SDL/Vulkan client and port tests.
# -DMTGODOT_BUILD_EXTENSION=OFF -DMTGODOT_BUILD_PORT=ON) for targets without an extension build.
# See docs/PORT-PLAN.md batch 2A. # See docs/PORT-PLAN.md batch 2A.
file(GLOB_RECURSE MT_PORT_SOURCES CONFIGURE_DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/*.cpp) file(GLOB_RECURSE MT_PORT_SOURCES CONFIGURE_DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/*.cpp)
@@ -31,8 +30,7 @@ target_include_directories(port_logic PUBLIC ${MT_PORT_SHIMS})
# Libraries 40250 links that are vendored as-is: <lzo/lzo1x.h> (EterBase/lzo.h), <cryptopp/*> # Libraries 40250 links that are vendored as-is: <lzo/lzo1x.h> (EterBase/lzo.h), <cryptopp/*>
# (EterBase/cipher.h), <Python-2.7/*> (ScriptLib). # (EterBase/cipher.h), <Python-2.7/*> (ScriptLib).
target_link_libraries(port_logic PUBLIC mt3p::minilzo mt3p::cryptopp) target_link_libraries(port_logic PUBLIC mt3p::minilzo mt3p::cryptopp)
# common/Win32Crt.cpp converts ANSI code pages with mt_codepage. extension/CMakeLists.txt adds it for # common/Win32Crt.cpp converts ANSI code pages with mt_codepage.
# mtnet; the port-only build (script/port_gate.sh, native_render) never reads that file.
if(NOT TARGET mt_codepage) if(NOT TARGET mt_codepage)
add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/../codepage ${CMAKE_CURRENT_BINARY_DIR}/../codepage) add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/../codepage ${CMAKE_CURRENT_BINARY_DIR}/../codepage)
endif() endif()
@@ -241,8 +239,7 @@ if(BUILD_TESTING AND CMAKE_SYSTEM_NAME STREQUAL CMAKE_HOST_SYSTEM_NAME)
# Locale packs in the original format (script/build_zh_locale.py packs locale/zh with it). # Locale packs in the original format (script/build_zh_locale.py packs locale/zh with it).
add_executable(eterpack_pack ${CMAKE_CURRENT_SOURCE_DIR}/../../tests/eterpack_pack.cpp) add_executable(eterpack_pack ${CMAKE_CURRENT_SOURCE_DIR}/../../tests/eterpack_pack.cpp)
target_link_libraries(eterpack_pack PRIVATE port_platform) target_link_libraries(eterpack_pack PRIVATE port_platform)
# 2V2-e: the same fake server as a process, for the Godot-driven render run # The same fake server as a process, for native rendering tests.
# (script/python_game_render_test.sh).
add_executable(port_fake_login_server ${CMAKE_CURRENT_SOURCE_DIR}/../../tests/port_fake_login_server_main.cpp add_executable(port_fake_login_server ${CMAKE_CURRENT_SOURCE_DIR}/../../tests/port_fake_login_server_main.cpp
${CMAKE_CURRENT_SOURCE_DIR}/../../tests/port_login_flow_server.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../../tests/port_login_flow_server.cpp
${CMAKE_CURRENT_SOURCE_DIR}/../net/classic/classic_cipher.cpp) ${CMAKE_CURRENT_SOURCE_DIR}/../net/classic/classic_cipher.cpp)
+38
View File
@@ -33,6 +33,32 @@
#include <iostream> #include <iostream>
#include <fstream> #include <fstream>
#if !defined(_WIN32)
#include <dirent.h> // PORT: resolve separately cased .eix/.epk names on case-sensitive mobile filesystems
#include <strings.h> // PORT: strcasecmp for the on-disk pack name lookup
// PORT: 40250 ships pairs such as ETC.eix and Etc.epk. Windows opens both through the
// same stem; Android must locate each file using its actual on-disk spelling.
static std::string ResolvePackFileName(const std::string& requested)
{
const std::string::size_type slash = requested.find_last_of('/');
const std::string folder = slash == std::string::npos ? "." : requested.substr(0, slash);
const std::string name = requested.substr(slash == std::string::npos ? 0 : slash + 1);
DIR* dir = opendir(folder.c_str());
if (!dir) return requested;
std::string result = requested;
while (dirent* entry = readdir(dir))
{
if (strcasecmp(entry->d_name, name.c_str()) == 0)
{
result = folder + "/" + entry->d_name;
break;
}
}
closedir(dir);
return result;
}
#endif
void CMakePackLog::SetFileName(const char* c_szFileName) void CMakePackLog::SetFileName(const char* c_szFileName)
{ {
@@ -257,6 +283,18 @@ bool CEterPack::Create(CEterFileDict& rkFileDict, const char * dbname, const cha
m_stDataFileName = dbname; m_stDataFileName = dbname;
m_stDataFileName += ".epk"; m_stDataFileName += ".epk";
// PORT: the two extensions may have different capitalization in the original Client/pack.
#if !defined(_WIN32)
if (bReadOnly)
{
const std::string indexName = ResolvePackFileName(m_indexFileName);
if (indexName.size() < sizeof(m_indexFileName))
{
strcpy(m_indexFileName, indexName.c_str());
}
m_stDataFileName = ResolvePackFileName(m_stDataFileName);
}
#endif
m_bReadOnly = bReadOnly; m_bReadOnly = bReadOnly;
@@ -1,4 +1,6 @@
#include "StdAfx.h" #include "StdAfx.h"
#include <algorithm>
#include <cctype>
#include "../EterBase/CRC32.h" #include "../EterBase/CRC32.h"
#include "PythonWindow.h" #include "PythonWindow.h"
#include "PythonSlotWindow.h" #include "PythonSlotWindow.h"
@@ -1392,7 +1394,7 @@ namespace UI
return FALSE; return FALSE;
} }
CExpandedImageBox::CExpandedImageBox(PyObject * ppyObject) : CImageBox(ppyObject) CExpandedImageBox::CExpandedImageBox(PyObject * ppyObject) : CImageBox(ppyObject), m_bAspectFill(false)
{ {
} }
CExpandedImageBox::~CExpandedImageBox() CExpandedImageBox::~CExpandedImageBox()
@@ -1400,6 +1402,73 @@ namespace UI
OnDestroyInstance(); OnDestroyInstance();
} }
void CExpandedImageBox::SetAspectFill(bool bEnable)
{
m_bAspectFill = bEnable;
}
bool CExpandedImageBox::IsAutoAspectFillBackground() const
{
const char* c_szFileName = "";
if (m_pImageInstance && m_pImageInstance->GetGraphicImagePointer())
c_szFileName = m_pImageInstance->GetGraphicImagePointer()->GetFileName();
if (!c_szFileName)
c_szFileName = "";
std::string lowerFile = c_szFileName;
std::transform(lowerFile.begin(), lowerFile.end(), lowerFile.begin(), ::tolower);
const std::string& name = m_strName;
std::string parentName = m_pParent ? m_pParent->GetName() : "";
// 1. Server list and login backgrounds (LoginWindow bg1 and bg2)
if (name == "bg1" || name == "bg2")
{
if (parentName == "LoginWindow")
return true;
if (lowerFile.find("serverlist") != std::string::npos || lowerFile.find("login") != std::string::npos)
return true;
}
// 2. Character selection & creation backgrounds (BackGround with select.sub / select.jpg)
if (name == "BackGround")
{
if (parentName == "SelectCharacterWindow" || parentName == "CreateCharacterWindow")
return true;
if (lowerFile.find("select.sub") != std::string::npos ||
lowerFile.find("select.jpg") != std::string::npos ||
lowerFile.find("select.png") != std::string::npos ||
lowerFile.find("ui/select") != std::string::npos)
return true;
}
// 3. Loading screen backgrounds (BackGround with Line_Pattern.tga or uiloading/*.sub)
if (name == "BackGround")
{
if (parentName == "LoadingWindow")
return true;
if (lowerFile.find("uiloading") != std::string::npos ||
lowerFile.find("line_pattern") != std::string::npos)
return true;
}
// Fallback check by file name directly if element name wasn't set or is generic
if (lowerFile.find("serverlist.sub") != std::string::npos ||
lowerFile.find("serverlist.jpg") != std::string::npos ||
lowerFile.find("uiloading/") != std::string::npos ||
lowerFile.find("uiloading\\") != std::string::npos)
{
return true;
}
return false;
}
bool CExpandedImageBox::IsAspectFill() const
{
return m_bAspectFill || IsAutoAspectFillBackground();
}
void CExpandedImageBox::OnCreateInstance() void CExpandedImageBox::OnCreateInstance()
{ {
OnDestroyInstance(); OnDestroyInstance();
@@ -1420,9 +1489,56 @@ namespace UI
if (!m_pImageInstance) if (!m_pImageInstance)
return; return;
if (IsAspectFill())
{
long screenWidth = UI::CWindowManager::Instance().GetScreenWidth();
long screenHeight = UI::CWindowManager::Instance().GetScreenHeight();
int imgWidth = GetWidth();
int imgHeight = GetHeight();
if (screenWidth > 0 && screenHeight > 0 && imgWidth > 0 && imgHeight > 0)
{
float sx = float(screenWidth) / float(imgWidth);
float sy = float(screenHeight) / float(imgHeight);
float s = std::max(sx, sy);
((CGraphicExpandedImageInstance*)m_pImageInstance)->SetScale(s, s);
CWindow::SetSize(screenWidth, screenHeight);
float posX = (float(screenWidth) - float(imgWidth) * s) * 0.5f;
float posY = (float(screenHeight) - float(imgHeight) * s) * 0.5f;
m_pImageInstance->SetPosition(posX, posY);
return;
}
}
((CGraphicExpandedImageInstance*)m_pImageInstance)->SetScale(fx, fy); ((CGraphicExpandedImageInstance*)m_pImageInstance)->SetScale(fx, fy);
CWindow::SetSize(long(float(GetWidth())*fx), long(float(GetHeight())*fy)); CWindow::SetSize(long(float(GetWidth())*fx), long(float(GetHeight())*fy));
} }
void CExpandedImageBox::OnChangePosition()
{
if (!m_pImageInstance)
return;
if (IsAspectFill())
{
long screenWidth = UI::CWindowManager::Instance().GetScreenWidth();
long screenHeight = UI::CWindowManager::Instance().GetScreenHeight();
int imgWidth = GetWidth();
int imgHeight = GetHeight();
if (screenWidth > 0 && screenHeight > 0 && imgWidth > 0 && imgHeight > 0)
{
float sx = float(screenWidth) / float(imgWidth);
float sy = float(screenHeight) / float(imgHeight);
float s = std::max(sx, sy);
float posX = (float(screenWidth) - float(imgWidth) * s) * 0.5f;
float posY = (float(screenHeight) - float(imgHeight) * s) * 0.5f;
m_pImageInstance->SetPosition(posX, posY);
return;
}
}
CImageBox::OnChangePosition();
}
void CExpandedImageBox::SetOrigin(float fx, float fy) void CExpandedImageBox::SetOrigin(float fx, float fy)
{ {
if (!m_pImageInstance) if (!m_pImageInstance)
@@ -1458,6 +1574,24 @@ namespace UI
if (!m_pImageInstance) if (!m_pImageInstance)
return; return;
if (IsAspectFill())
{
long screenWidth = UI::CWindowManager::Instance().GetScreenWidth();
long screenHeight = UI::CWindowManager::Instance().GetScreenHeight();
int imgWidth = GetWidth();
int imgHeight = GetHeight();
if (screenWidth > 0 && screenHeight > 0 && imgWidth > 0 && imgHeight > 0)
{
float sx = float(screenWidth) / float(imgWidth);
float sy = float(screenHeight) / float(imgHeight);
float s = std::max(sx, sy);
((CGraphicExpandedImageInstance*)m_pImageInstance)->SetScale(s, s);
float posX = (float(screenWidth) - float(imgWidth) * s) * 0.5f;
float posY = (float(screenHeight) - float(imgHeight) * s) * 0.5f;
m_pImageInstance->SetPosition(posX, posY);
}
}
if (IsShow()) if (IsShow())
m_pImageInstance->Render(); m_pImageInstance->Render();
} }
@@ -386,6 +386,8 @@ namespace UI
void SetRotation(float fRotation); void SetRotation(float fRotation);
void SetRenderingRect(float fLeft, float fTop, float fRight, float fBottom); void SetRenderingRect(float fLeft, float fTop, float fRight, float fBottom);
void SetRenderingMode(int iMode); void SetRenderingMode(int iMode);
void SetAspectFill(bool bEnable);
bool IsAspectFill() const;
protected: protected:
void OnCreateInstance(); void OnCreateInstance();
@@ -393,8 +395,14 @@ namespace UI
virtual void OnUpdate(); virtual void OnUpdate();
virtual void OnRender(); virtual void OnRender();
virtual void OnChangePosition();
BOOL OnIsType(DWORD dwType); BOOL OnIsType(DWORD dwType);
bool IsAutoAspectFillBackground() const;
protected:
bool m_bAspectFill;
}; };
class CAniImageBox : public CWindow class CAniImageBox : public CWindow
{ {
@@ -2012,6 +2012,27 @@ PyObject * wndImageSetScale(PyObject * poSelf, PyObject * poArgs)
return Py_BuildNone(); return Py_BuildNone();
} }
PyObject * wndImageSetAspectFill(PyObject * poSelf, PyObject * poArgs)
{
UI::CWindow * pWindow;
if (!PyTuple_GetWindow(poArgs, 0, &pWindow))
return Py_BuildException();
bool bEnable = true;
if (PyTuple_Size(poArgs) > 1)
{
int iEnable;
if (PyTuple_GetInteger(poArgs, 1, &iEnable))
bEnable = (iEnable != 0);
}
if (pWindow->IsType(UI::CExpandedImageBox::Type()))
{
((UI::CExpandedImageBox*)pWindow)->SetAspectFill(bEnable);
}
return Py_BuildNone();
}
PyObject * wndImageSetOrigin(PyObject * poSelf, PyObject * poArgs) PyObject * wndImageSetOrigin(PyObject * poSelf, PyObject * poArgs)
{ {
UI::CWindow * pWindow; UI::CWindow * pWindow;
@@ -2454,6 +2475,7 @@ void initwndMgr()
{ "GetHeight", wndImageGetHeight, METH_VARARGS }, { "GetHeight", wndImageGetHeight, METH_VARARGS },
// ExpandedImageBox // ExpandedImageBox
{ "SetScale", wndImageSetScale, METH_VARARGS }, { "SetScale", wndImageSetScale, METH_VARARGS },
{ "SetAspectFill", wndImageSetAspectFill, METH_VARARGS },
{ "SetOrigin", wndImageSetOrigin, METH_VARARGS }, { "SetOrigin", wndImageSetOrigin, METH_VARARGS },
{ "SetRotation", wndImageSetRotation, METH_VARARGS }, { "SetRotation", wndImageSetRotation, METH_VARARGS },
{ "SetRenderingRect", wndImageSetRenderingRect, METH_VARARGS }, { "SetRenderingRect", wndImageSetRenderingRect, METH_VARARGS },
-164
View File
@@ -1,164 +0,0 @@
#include "proto_node.h"
#include "../asset_io.h"
#include <godot_cpp/core/class_db.hpp>
#include <string>
using namespace godot;
namespace mtgodot {
namespace {
// szName is the Korean (CP949) development name; what 40250 shows is szLocaleName (CItemData::GetName,
// CPythonNonPlayer::GetMonsterName) in the locale code page, 1252 for locale.cfg "10002 1252 en".
String from_cp1252(const std::string &s) {
static const char32_t k80[32] = {
0x20AC, 0xFFFD, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0x0160, 0x2039, 0x0152, 0xFFFD, 0x017D, 0xFFFD,
0xFFFD, 0x2018, 0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0x02DC, 0x2122, 0x0161, 0x203A, 0x0153, 0xFFFD, 0x017E, 0x0178,
};
String out;
for (unsigned char c : s) {
out += String::chr(c >= 0x80 && c < 0xA0 ? k80[c - 0x80] : static_cast<char32_t>(c));
}
return out;
}
} // namespace
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"] = from_cp1252(r.locale_name); // CItemData::GetName()
d["locale_name"] = from_cp1252(r.locale_name);
d["type"] = (int)r.type;
d["sub_type"] = (int)r.sub_type;
d["weight"] = (int)r.weight;
d["size"] = (int)r.size;
d["anti_flags"] = (int)r.anti_flags;
d["flags"] = (int)r.flags;
d["wear_flags"] = (int)r.wear_flags;
d["immune_flag"] = (int)r.immune_flag;
d["buy_price"] = (int)r.buy_price;
d["sell_price"] = (int)r.sell_price;
{
Array limits;
for (const mtproto::ItemLimit &limit : r.limits) {
Dictionary entry;
entry["type"] = (int)limit.type;
entry["value"] = (int)limit.value;
limits.push_back(entry);
}
d["limits"] = limits;
}
{
Array applies;
for (const mtproto::ItemApply &apply : r.applies) {
Dictionary entry;
entry["type"] = (int)apply.type;
entry["value"] = (int)apply.value;
applies.push_back(entry);
}
d["applies"] = applies;
}
{
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
}
{
Array sockets;
for (int socket : r.sockets) {
sockets.push_back(socket);
}
d["sockets"] = sockets;
}
d["refined_vnum"] = (int)r.refined_vnum;
d["refine_set"] = (int)r.refine_set;
d["alter_to_magic_pct"] = (int)r.alter_to_magic_pct;
d["specular"] = (int)r.specular;
d["gain_socket_pct"] = (int)r.gain_socket_pct;
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"] = from_cp1252(r.locale_name); // CPythonNonPlayer::GetMonsterName()
d["locale_name"] = from_cp1252(r.locale_name);
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;
d["attack_speed"] = (int)r.attack_speed;
d["moving_speed"] = (int)r.moving_speed;
d["on_click_type"] = (int)r.on_click_type;
d["empire"] = (int)r.empire;
d["folder"] = String(r.folder.c_str());
d["monster_color"] = (int64_t)r.monster_color;
return d;
}
} // namespace mtgodot
-48
View File
@@ -1,48 +0,0 @@
#pragma once
// Metin2Proto — GDExtension node exposing item_proto / mob_proto to GDScript.
//
// var proto = Metin2Proto.new()
// proto.load_item_proto("pack://locale/en/item_proto") # 40250 format, via asset_io
// 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
-669
View File
@@ -1,669 +0,0 @@
#include "python_host_node.h"
#include <godot_cpp/core/class_db.hpp>
#include <godot_cpp/variant/utility_functions.hpp>
#include "python_stdlib.h"
#ifdef MTGODOT_HAVE_PYTHON
// Standard types only — PythonBoot.h keeps <Python-2.7/*> and the Win32 shims out of this translation
// unit, which has to stay compilable next to godot-cpp.
#include "platform/ScriptLib/PythonBoot.h"
#include "platform/EterLib/UIRenderCommands.h"
#include "platform/EterLib/RenderCommands3D.h"
#include "platform/MilesLib/AudioCommands.h"
#include "platform/PackBackend.h"
#include "../../native_render/draw_capture.h"
#include <cstdlib>
#include <cstring>
#include <string>
#include <type_traits>
#include <unordered_map>
#endif
#include <godot_cpp/classes/image.hpp>
#include <godot_cpp/variant/dictionary.hpp>
#include <godot_cpp/variant/packed_byte_array.hpp>
#include <godot_cpp/variant/packed_color_array.hpp>
#include <godot_cpp/variant/packed_float32_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/string_name.hpp>
using namespace godot;
namespace mtgodot {
#ifdef MTGODOT_HAVE_PYTHON
String Metin2PythonHost::start(const String &stdlib_path) {
String path = stdlib_path;
if (path.is_empty()) {
path = Metin2Python::stdlib_path(false);
if (path.is_empty()) {
return String("python27.zip unavailable: ") + Metin2Python::last_error();
}
}
std::string error;
if (!PythonBoot::Start(path.utf8().get_data(), &error)) {
return String::utf8(error.c_str());
}
return String();
}
bool Metin2PythonHost::is_running() {
return PythonBoot::IsRunning();
}
String Metin2PythonHost::run_main_script(const String &command_line) {
std::string error;
if (!PythonBoot::RunMainScript(command_line.utf8().get_data(), &error)) {
return String::utf8(error.c_str());
}
return String();
}
String Metin2PythonHost::run_line(const String &source) {
std::string error;
if (!PythonBoot::RunLine(source.utf8().get_data(), &error)) {
return String::utf8(error.c_str());
}
return String();
}
// str(expression) from __main__, or "" when it raised (the error goes to the log like run_line's).
String Metin2PythonHost::evaluate(const String &expression) {
std::string result, error;
if (!PythonBoot::Evaluate(expression.utf8().get_data(), &result, &error)) {
UtilityFunctions::push_error(String::utf8(error.c_str()));
return String();
}
return String::utf8(result.c_str());
}
bool Metin2PythonHost::is_app_looping() { return PythonBoot::IsAppLooping(); }
void Metin2PythonHost::set_ui_size(int width, int height) { PythonBoot::SetUISize(width, height); }
void Metin2PythonHost::ui_mouse_move(int x, int y) { PythonBoot::UIMouseMove(x, y); }
void Metin2PythonHost::ui_mouse_button(int button, bool pressed, int x, int y) {
PythonBoot::UIMouseButton(button, pressed, x, y);
}
void Metin2PythonHost::ui_mouse_wheel(int delta) { PythonBoot::UIMouseWheel(delta); }
void Metin2PythonHost::ui_key(int key, bool pressed) { PythonBoot::UIKey(key, pressed); }
void Metin2PythonHost::ui_char(int codepoint) { PythonBoot::UIChar(unsigned(codepoint)); }
void Metin2PythonHost::ui_ime_key(int vkey) { PythonBoot::UIIMEKeyDown(vkey); }
void Metin2PythonHost::ui_update() { PythonBoot::UIUpdate(); }
namespace {
Dictionary ui_command_item(const UIRenderCommand &command) {
static const StringName s_kind("kind");
static const StringName s_x1("x1");
static const StringName s_y1("y1");
static const StringName s_x2("x2");
static const StringName s_y2("y2");
static const StringName s_argb("argb");
static const StringName s_end_argb("end_argb");
static const StringName s_clip_x1("clip_x1");
static const StringName s_clip_y1("clip_y1");
static const StringName s_clip_x2("clip_x2");
static const StringName s_clip_y2("clip_y2");
static const StringName s_text("text");
static const StringName s_quad("quad");
static const StringName s_uv("uv");
static const StringName s_blend("blend");
static const StringName s_mask("mask");
static const StringName s_mask_uv("mask_uv");
static const StringName s_behind_3d("behind_3d");
static const StringName s_val_bar("bar");
static const StringName s_val_gradient_bar("gradient_bar");
static const StringName s_val_line("line");
static const StringName s_val_image("image");
static const StringName s_val_text("text");
Dictionary item;
item[s_kind] = command.kind == UIRenderCommand::GradientBar ? s_val_gradient_bar :
(command.kind == UIRenderCommand::Bar ? s_val_bar :
(command.kind == UIRenderCommand::Line ? s_val_line :
(command.kind == UIRenderCommand::Image ? s_val_image : s_val_text)));
item[s_x1] = command.x1;
item[s_y1] = command.y1;
item[s_x2] = command.x2;
item[s_y2] = command.y2;
item[s_argb] = static_cast<int64_t>(command.argb);
if (command.kind == UIRenderCommand::GradientBar)
item[s_end_argb] = static_cast<int64_t>(command.end_argb);
item[s_clip_x1] = command.clip_x1;
item[s_clip_y1] = command.clip_y1;
item[s_clip_x2] = command.clip_x2;
item[s_clip_y2] = command.clip_y2;
if (command.kind == UIRenderCommand::Text || command.kind == UIRenderCommand::Image)
item[s_text] = String::utf8(command.text.c_str());
if (command.quad) {
PackedVector2Array quad;
quad.resize(4);
Vector2 *qptr = quad.ptrw();
for (int i = 0; i < 4; ++i)
qptr[i] = Vector2(command.qx[i], command.qy[i]);
item[s_quad] = quad;
PackedVector2Array uv;
uv.resize(4);
Vector2 *uvptr = uv.ptrw();
uvptr[0] = Vector2(command.su, command.sv);
uvptr[1] = Vector2(command.eu, command.sv);
uvptr[2] = Vector2(command.su, command.ev);
uvptr[3] = Vector2(command.eu, command.ev);
item[s_uv] = uv;
item[s_blend] = command.blend;
if (!command.mask.empty()) {
item[s_mask] = String::utf8(command.mask.c_str());
PackedVector2Array mask_uv;
mask_uv.resize(4);
Vector2 *mptr = mask_uv.ptrw();
for (int i = 0; i < 4; ++i)
mptr[i] = Vector2(command.mu[i], command.mv[i]);
item[s_mask_uv] = mask_uv;
}
}
item[s_behind_3d] = command.behind_3d;
return item;
}
} // namespace
Array Metin2PythonHost::ui_render_commands() {
PythonBoot::UIRender();
static std::uint64_t cached_frame_id = UINT64_MAX;
static Array cached_commands;
const std::uint64_t frame_id = UIRenderFrameId();
if (cached_frame_id == frame_id)
return cached_commands;
const auto &commands = UIRenderCommands();
Array out;
out.resize(static_cast<int64_t>(commands.size()));
for (size_t idx = 0; idx < commands.size(); ++idx)
out[static_cast<int64_t>(idx)] = ui_command_item(commands[idx]);
cached_frame_id = frame_id;
cached_commands = out;
return out;
}
Array Metin2PythonHost::ui_render_commands_batched() {
PythonBoot::UIRender();
static std::uint64_t cached_frame_id = UINT64_MAX;
static Array cached_commands;
const std::uint64_t frame_id = UIRenderFrameId();
if (cached_frame_id == frame_id)
return cached_commands;
const auto &commands = UIRenderCommands();
const auto batchable = [](const UIRenderCommand &c) {
return c.kind == UIRenderCommand::Image && c.quad && c.text.rfind("mem:", 0) == 0 &&
c.mask.empty() && c.blend == 0 && c.x1 >= c.clip_x1 && c.y1 >= c.clip_y1 &&
c.x2 <= c.clip_x2 && c.y2 <= c.clip_y2;
};
Array out;
for (size_t i = 0; i < commands.size();) {
const UIRenderCommand &first = commands[i];
if (!batchable(first)) {
out.push_back(ui_command_item(first));
++i;
continue;
}
size_t end = i + 1;
while (end < commands.size() && batchable(commands[end]) &&
commands[end].text == first.text && commands[end].behind_3d == first.behind_3d)
++end;
if (end == i + 1) {
out.push_back(ui_command_item(first));
i = end;
continue;
}
const int64_t count = static_cast<int64_t>(end - i);
PackedVector2Array points, uvs;
PackedColorArray colors;
PackedInt32Array indices;
points.resize(count * 4);
uvs.resize(count * 4);
colors.resize(count * 4);
indices.resize(count * 6);
Vector2 *point_data = points.ptrw(), *uv_data = uvs.ptrw();
Color *color_data = colors.ptrw();
int32_t *index_data = indices.ptrw();
for (int64_t j = 0; j < count; ++j) {
const UIRenderCommand &c = commands[i + static_cast<size_t>(j)];
const int64_t v = j * 4, t = j * 6;
point_data[v] = Vector2(c.qx[0], c.qy[0]);
point_data[v + 1] = Vector2(c.qx[1], c.qy[1]);
point_data[v + 2] = Vector2(c.qx[3], c.qy[3]);
point_data[v + 3] = Vector2(c.qx[2], c.qy[2]);
uv_data[v] = Vector2(c.su, c.sv);
uv_data[v + 1] = Vector2(c.eu, c.sv);
uv_data[v + 2] = Vector2(c.eu, c.ev);
uv_data[v + 3] = Vector2(c.su, c.ev);
const Color color(((c.argb >> 16) & 255) / 255.0f, ((c.argb >> 8) & 255) / 255.0f,
(c.argb & 255) / 255.0f, ((c.argb >> 24) & 255) / 255.0f);
for (int k = 0; k < 4; ++k)
color_data[v + k] = color;
const int32_t base = static_cast<int32_t>(v);
index_data[t] = base;
index_data[t + 1] = base + 1;
index_data[t + 2] = base + 2;
index_data[t + 3] = base;
index_data[t + 4] = base + 2;
index_data[t + 5] = base + 3;
}
Dictionary item;
item["kind"] = StringName("glyph_batch");
item["text"] = String::utf8(first.text.c_str());
item["behind_3d"] = first.behind_3d;
item["points"] = points;
item["uvs"] = uvs;
item["colors"] = colors;
item["indices"] = indices;
item["glyph_count"] = count;
out.push_back(item);
i = end;
}
cached_frame_id = frame_id;
cached_commands = out;
return out;
}
bool Metin2PythonHost::has_3d_draws() { return !Render3DDraws().empty(); }
Ref<Image> Metin2PythonHost::memory_texture(const String &name, int64_t known_revision) {
UIMemoryTexture texture;
if (!UIRenderMemoryTexture(name.utf8().get_data(), &texture) || texture.width <= 0 || texture.height <= 0)
return Ref<Image>();
if (known_revision >= 0 && texture.revision == static_cast<std::uint32_t>(known_revision))
return Ref<Image>();
PackedByteArray rgba;
rgba.resize(static_cast<int64_t>(texture.argb.size()) * 4);
uint8_t *out = rgba.ptrw();
for (std::uint32_t argb : texture.argb) {
*out++ = static_cast<uint8_t>(argb >> 16);
*out++ = static_cast<uint8_t>(argb >> 8);
*out++ = static_cast<uint8_t>(argb);
*out++ = static_cast<uint8_t>(argb >> 24);
}
return Image::create_from_data(texture.width, texture.height, false, Image::FORMAT_RGBA8, rgba);
}
namespace {
PackedFloat32Array floats(const float *values, int count) {
PackedFloat32Array out;
out.resize(count);
for (int i = 0; i < count; ++i)
out[i] = values[i];
return out;
}
Color argb_color(std::uint32_t argb) {
return Color(((argb >> 16) & 255) / 255.0f, ((argb >> 8) & 255) / 255.0f, (argb & 255) / 255.0f,
((argb >> 24) & 255) / 255.0f);
}
bool read_pack_texture(const std::string &vpath, std::vector<std::uint8_t> &bytes) {
if (vpath.empty() || !mtpack40250::ready())
return false;
std::string norm = vpath;
for (char &ch : norm)
if (ch == '\\')
ch = '/';
std::string stripped = norm;
if (stripped.size() >= 2 && stripped[1] == ':')
stripped = stripped.substr(2);
while (!stripped.empty() && stripped.front() == '/')
stripped.erase(stripped.begin());
auto lower = [](std::string s) {
for (char &ch : s)
if (ch >= 'A' && ch <= 'Z')
ch = static_cast<char>(ch - 'A' + 'a');
return s;
};
for (const std::string &candidate : {
norm, stripped, "d:/" + stripped,
lower(norm), lower(stripped), lower("d:/" + stripped)}) {
if (mtpack40250::read(candidate, bytes) && !bytes.empty())
return true;
}
return false;
}
} // namespace
Array Metin2PythonHost::render3d_draws() {
struct CachedGeometry {
std::uint64_t revision;
std::uint64_t last_used;
Dictionary arrays;
};
static std::unordered_map<std::uint64_t, CachedGeometry> geometry_cache;
static std::uint64_t extraction = 0;
++extraction;
const auto &draws = Render3DDraws();
static bool capture_written = false;
if (!capture_written) {
const char *capture_path = std::getenv("MT_NATIVE_CAPTURE_PATH");
const char *minimum_text = std::getenv("MT_NATIVE_CAPTURE_MIN_DRAWS");
const unsigned long minimum = minimum_text ? std::strtoul(minimum_text, nullptr, 10) : 100UL;
if (capture_path && *capture_path && draws.size() >= minimum) {
capture_written = true;
try {
std::unordered_map<std::string, std::vector<std::uint8_t>> textures;
auto add_texture = [&](const std::string &name) {
if (name.empty() || textures.count(name))
return;
if (name.rfind("mem:", 0) == 0) {
UIMemoryTexture mem_tex;
if (UIRenderMemoryTexture(name, &mem_tex) && mem_tex.width > 0 && mem_tex.height > 0) {
auto mtra = native_draw_capture::encode_raw_argb_as_mtra(
static_cast<std::uint32_t>(mem_tex.width),
static_cast<std::uint32_t>(mem_tex.height),
mem_tex.argb.data());
if (!mtra.empty())
textures.emplace(name, std::move(mtra));
}
return;
}
std::vector<std::uint8_t> bytes;
if (read_pack_texture(name, bytes))
textures.emplace(name, std::move(bytes));
};
for (const Render3DDraw &draw : draws) {
add_texture(draw.texture0);
add_texture(draw.texture1);
}
const auto &ui_commands = UIRenderCommands();
for (const UIRenderCommand &cmd : ui_commands) {
if (cmd.kind == UIRenderCommand::Image) {
add_texture(cmd.text);
add_texture(cmd.mask);
}
}
unsigned ui_w = 0, ui_h = 0;
UIRenderGetSize(&ui_w, &ui_h);
native_draw_capture::write(
capture_path, draws, textures,
ui_w ? ui_w : 960u, ui_h ? ui_h : 640u, ui_commands);
UtilityFunctions::print(
"native draw capture: ", capture_path,
" draws=", static_cast<int64_t>(draws.size()),
" ui_commands=", static_cast<int64_t>(ui_commands.size()),
" textures=", static_cast<int64_t>(textures.size()));
} catch (const std::exception &error) {
UtilityFunctions::printerr("native draw capture failed: ", error.what());
}
}
}
Array out;
out.resize(static_cast<int64_t>(draws.size()));
int64_t draw_index = 0;
for (const Render3DDraw &draw : draws) {
if (draw.clear_flags)
continue; // back-buffer clears are consumed by the native renderer only
Dictionary item;
item["geometry_key"] = static_cast<int64_t>(draw.geometry_key);
item["geometry_revision"] = static_cast<int64_t>(draw.geometry_revision);
item["world"] = floats(draw.world, 16);
item["view"] = floats(draw.view, 16);
item["proj"] = floats(draw.proj, 16);
item["texture0"] = String::utf8(draw.texture0.c_str());
item["texture1"] = String::utf8(draw.texture1.c_str());
item["pretransformed"] = draw.pretransformed;
item["lines"] = draw.lines;
// Stage-0 coordinates the D3D8 pipeline generates or transforms depend on per-draw matrices,
// so such draws carry them outside the geometry cache.
const bool stage0_texgen = (draw.texcoord_index[0] & 0xFFFF0000u) != 0 ||
(draw.texcoord_index[0] & 0xFFFFu) != 0 || draw.texture_transform_flags[0] != 0;
if (stage0_texgen)
item["geometry_key"] = static_cast<int64_t>(0);
Dictionary geometry;
if (draw.geometry_key != 0 && !stage0_texgen) {
auto cached = geometry_cache.find(draw.geometry_key);
if (cached != geometry_cache.end() && cached->second.revision == draw.geometry_revision) {
cached->second.last_used = extraction;
geometry = cached->second.arrays;
}
}
if (geometry.is_empty()) {
PackedVector3Array positions;
positions.resize(static_cast<int64_t>(draw.positions.size() / 3));
if constexpr (sizeof(Vector3) == sizeof(float) * 3 && std::is_trivially_copyable_v<Vector3>) {
if (!draw.positions.empty())
std::memcpy(positions.ptrw(), draw.positions.data(), draw.positions.size() * sizeof(float));
} else {
Vector3 *dst = positions.ptrw();
for (int64_t i = 0; i < positions.size(); ++i)
dst[i] = Vector3(draw.positions[i * 3], draw.positions[i * 3 + 1], draw.positions[i * 3 + 2]);
}
geometry["positions"] = positions;
if (!draw.rhw.empty())
geometry["rhw"] = floats(draw.rhw.data(), static_cast<int>(draw.rhw.size()));
if (!draw.normals.empty()) {
PackedVector3Array normals;
normals.resize(static_cast<int64_t>(draw.normals.size() / 3));
if constexpr (sizeof(Vector3) == sizeof(float) * 3 && std::is_trivially_copyable_v<Vector3>) {
std::memcpy(normals.ptrw(), draw.normals.data(), draw.normals.size() * sizeof(float));
} else {
Vector3 *dst = normals.ptrw();
for (int64_t i = 0; i < normals.size(); ++i)
dst[i] = Vector3(draw.normals[i * 3], draw.normals[i * 3 + 1], draw.normals[i * 3 + 2]);
}
geometry["normals"] = normals;
}
auto uvs = [](const std::vector<float> &values) {
PackedVector2Array out;
out.resize(static_cast<int64_t>(values.size() / 2));
if constexpr (sizeof(Vector2) == sizeof(float) * 2 && std::is_trivially_copyable_v<Vector2>) {
if (!values.empty())
std::memcpy(out.ptrw(), values.data(), values.size() * sizeof(float));
} else {
Vector2 *dst = out.ptrw();
for (int64_t i = 0; i < out.size(); ++i)
dst[i] = Vector2(values[i * 2], values[i * 2 + 1]);
}
return out;
};
if (stage0_texgen) {
std::vector<float> stage_uv;
Render3DStageTexcoords(draw, 0, stage_uv);
geometry["uv0"] = uvs(stage_uv);
} else if (!draw.uv0.empty())
geometry["uv0"] = uvs(draw.uv0);
if (!draw.uv1.empty())
geometry["uv1"] = uvs(draw.uv1);
if (!draw.diffuse.empty()) {
PackedColorArray colors;
colors.resize(static_cast<int64_t>(draw.diffuse.size()));
for (int64_t i = 0; i < colors.size(); ++i)
colors[i] = argb_color(draw.diffuse[i]);
geometry["diffuse"] = colors;
}
PackedInt32Array indices;
indices.resize(static_cast<int64_t>(draw.indices.size()));
for (int64_t i = 0; i < indices.size(); ++i)
indices[i] = static_cast<int32_t>(draw.indices[i]);
geometry["indices"] = indices;
if (draw.geometry_key != 0 && !stage0_texgen)
geometry_cache[draw.geometry_key] = {draw.geometry_revision, extraction, geometry};
}
item.merge(geometry);
item["alpha_blend"] = static_cast<int64_t>(draw.alpha_blend);
item["src_blend"] = static_cast<int64_t>(draw.src_blend);
item["dest_blend"] = static_cast<int64_t>(draw.dest_blend);
item["alpha_test"] = static_cast<int64_t>(draw.alpha_test);
item["alpha_ref"] = static_cast<int64_t>(draw.alpha_ref);
item["alpha_func"] = static_cast<int64_t>(draw.alpha_func);
item["cull_mode"] = static_cast<int64_t>(draw.cull_mode);
item["z_enable"] = static_cast<int64_t>(draw.z_enable);
item["z_write"] = static_cast<int64_t>(draw.z_write);
item["z_func"] = static_cast<int64_t>(draw.z_func);
item["lighting"] = static_cast<int64_t>(draw.lighting);
item["texture_factor"] = static_cast<int64_t>(draw.texture_factor);
bool uses_tf = false;
for (int s = 0; s < 2; ++s) {
if (draw.color_op[s] > 1 &&
(((draw.color_arg1[s] & 0xF) == 3) || ((draw.color_arg2[s] & 0xF) == 3)))
uses_tf = true;
if (draw.alpha_op[s] > 1 &&
(((draw.alpha_arg1[s] & 0xF) == 3) || ((draw.alpha_arg2[s] & 0xF) == 3)))
uses_tf = true;
}
item["uses_tf"] = uses_tf;
item["fog_enable"] = static_cast<int64_t>(draw.fog_enable);
item["color_op"] = static_cast<int64_t>(draw.color_op[0]);
item["alpha_op"] = static_cast<int64_t>(draw.alpha_op[0]);
item["material_diffuse"] = Color(draw.material_diffuse[0], draw.material_diffuse[1],
draw.material_diffuse[2], draw.material_diffuse[3]);
item["material_ambient"] = Color(draw.material_ambient[0], draw.material_ambient[1],
draw.material_ambient[2], draw.material_ambient[3]);
item["material_emissive"] = Color(draw.material_emissive[0], draw.material_emissive[1],
draw.material_emissive[2], draw.material_emissive[3]);
item["light0"] = draw.light0;
item["light0_direction"] = Vector3(draw.light0_direction[0], draw.light0_direction[1],
draw.light0_direction[2]);
item["light0_diffuse"] = Color(draw.light0_diffuse[0], draw.light0_diffuse[1], draw.light0_diffuse[2],
draw.light0_diffuse[3]);
item["light0_ambient"] = Color(draw.light0_ambient[0], draw.light0_ambient[1], draw.light0_ambient[2],
draw.light0_ambient[3]);
item["ambient"] = argb_color(draw.ambient);
PackedFloat32Array vp;
vp.resize(4);
vp[0] = draw.viewport[0];
vp[1] = draw.viewport[1];
vp[2] = draw.viewport[2];
vp[3] = draw.viewport[3];
item["viewport"] = vp;
out[draw_index++] = item;
}
out.resize(draw_index);
if (extraction % 120 == 0) {
for (auto it = geometry_cache.begin(); it != geometry_cache.end();) {
if (extraction - it->second.last_used > 120)
it = geometry_cache.erase(it);
else
++it;
}
}
return out;
}
Array Metin2PythonHost::audio_commands() {
Array out;
for (const auto &cmd : DrainAudioCommands()) {
Dictionary item;
switch (cmd.type) {
case AudioCommand::PlaySound2D: item["type"] = "play_sound_2d"; break;
case AudioCommand::PlaySound3D: item["type"] = "play_sound_3d"; break;
case AudioCommand::StopSound3D: item["type"] = "stop_sound_3d"; break;
case AudioCommand::StopAllSound3D: item["type"] = "stop_all_sound_3d"; break;
case AudioCommand::PlayMusic: item["type"] = "play_music"; break;
case AudioCommand::FadeOutMusic: item["type"] = "fade_out_music"; break;
case AudioCommand::FadeOutAllMusic: item["type"] = "fade_out_all_music"; break;
case AudioCommand::SetSoundVolume: item["type"] = "set_sound_volume"; break;
case AudioCommand::SetMusicVolume: item["type"] = "set_music_volume"; break;
case AudioCommand::FadeInMusic: item["type"] = "fade_in_music"; break;
case AudioCommand::FadeLimitOutMusic: item["type"] = "fade_limit_out_music"; break;
case AudioCommand::SetSoundVolume3D: item["type"] = "set_sound_volume_3d"; break;
default: continue;
}
item["filename"] = String::utf8(cmd.filename.c_str());
item["x"] = cmd.x;
item["y"] = cmd.y;
item["z"] = cmd.z;
item["volume"] = cmd.volume;
item["speed"] = cmd.speed;
item["play_count"] = cmd.play_count;
item["id"] = cmd.id;
out.append(item);
}
return out;
}
String Metin2PythonHost::current_map_name() {
return String::utf8(PythonBoot::CurrentMapName().c_str());
}
void Metin2PythonHost::stop() {
PythonBoot::Stop();
}
#else
// Built without -DMTGODOT_EMBED_PYTHON=ON: the class is still registered so GDScript can tell "no
// interpreter in this build" apart from "the interpreter failed to start".
String Metin2PythonHost::start(const String &) {
return String("this build has no embedded Python (-DMTGODOT_EMBED_PYTHON=ON)");
}
bool Metin2PythonHost::is_running() {
return false;
}
String Metin2PythonHost::run_main_script(const String &) {
return String("this build has no embedded Python (-DMTGODOT_EMBED_PYTHON=ON)");
}
String Metin2PythonHost::run_line(const String &) {
return String("this build has no embedded Python (-DMTGODOT_EMBED_PYTHON=ON)");
}
String Metin2PythonHost::evaluate(const String &) { return String(); }
bool Metin2PythonHost::is_app_looping() { return false; }
void Metin2PythonHost::set_ui_size(int, int) {}
void Metin2PythonHost::ui_mouse_move(int, int) {}
void Metin2PythonHost::ui_mouse_button(int, bool, int, int) {}
void Metin2PythonHost::ui_mouse_wheel(int) {}
void Metin2PythonHost::ui_key(int, bool) {}
void Metin2PythonHost::ui_char(int) {}
void Metin2PythonHost::ui_ime_key(int) {}
void Metin2PythonHost::ui_update() {}
Array Metin2PythonHost::ui_render_commands() { return Array(); }
Array Metin2PythonHost::ui_render_commands_batched() { return Array(); }
bool Metin2PythonHost::has_3d_draws() { return false; }
Ref<Image> Metin2PythonHost::memory_texture(const String &, int64_t) { return Ref<Image>(); }
Array Metin2PythonHost::render3d_draws() { return Array(); }
Array Metin2PythonHost::audio_commands() { return Array(); }
String Metin2PythonHost::current_map_name() { return String(); }
void Metin2PythonHost::stop() {
}
#endif
void Metin2PythonHost::_bind_methods() {
ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("start", "stdlib_path"),
&Metin2PythonHost::start, DEFVAL(String()));
ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("is_running"), &Metin2PythonHost::is_running);
ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("evaluate", "expression"), &Metin2PythonHost::evaluate);
ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("run_main_script", "command_line"),
&Metin2PythonHost::run_main_script, DEFVAL(String()));
ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("run_line", "source"), &Metin2PythonHost::run_line);
ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("is_app_looping"), &Metin2PythonHost::is_app_looping);
ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("set_ui_size", "width", "height"), &Metin2PythonHost::set_ui_size);
ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("ui_mouse_move", "x", "y"), &Metin2PythonHost::ui_mouse_move);
ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("ui_mouse_button", "button", "pressed", "x", "y"), &Metin2PythonHost::ui_mouse_button);
ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("ui_mouse_wheel", "delta"), &Metin2PythonHost::ui_mouse_wheel);
ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("ui_key", "key", "pressed"), &Metin2PythonHost::ui_key);
ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("ui_char", "codepoint"), &Metin2PythonHost::ui_char);
ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("ui_ime_key", "vkey"), &Metin2PythonHost::ui_ime_key);
ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("ui_update"), &Metin2PythonHost::ui_update);
ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("ui_render_commands"), &Metin2PythonHost::ui_render_commands);
ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("ui_render_commands_batched"), &Metin2PythonHost::ui_render_commands_batched);
ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("has_3d_draws"), &Metin2PythonHost::has_3d_draws);
ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("memory_texture", "name", "known_revision"),
&Metin2PythonHost::memory_texture, DEFVAL(-1));
ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("render3d_draws"), &Metin2PythonHost::render3d_draws);
ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("audio_commands"), &Metin2PythonHost::audio_commands);
ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("current_map_name"), &Metin2PythonHost::current_map_name);
ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("stop"), &Metin2PythonHost::stop);
}
} // namespace mtgodot
-66
View File
@@ -1,66 +0,0 @@
#pragma once
// Metin2PythonHost — the 40250 script layer inside the Godot process (docs/PORT-PLAN.md 批次 2P step 4).
//
// Metin2Pack.initialize(AssetRoot.client_path()) # the packs system.py is read from
// if Metin2PythonHost.start() == "":
// var err := Metin2PythonHost.run_main_script("")
//
// Both calls answer with "" on success and with the reason otherwise, so GDScript never has to guess
// from a bool. 2V0-e reaches prototype.RunApp(); until 2V0-f implements the application adapter,
// app.Create reports CREATE_DEVICE and the original script displays its error popup.
//
// This is the only unit that knows both godot-cpp and the port tree: the sequence itself lives in
// platform/ScriptLib/PythonBoot.h, which stays godot-free.
#include <godot_cpp/classes/object.hpp>
#include <godot_cpp/variant/string.hpp>
#include <godot_cpp/variant/array.hpp>
#include <godot_cpp/classes/image.hpp>
#include <godot_cpp/classes/ref.hpp>
namespace mtgodot {
class Metin2PythonHost : public godot::Object {
GDCLASS(Metin2PythonHost, godot::Object)
public:
// Stages the standard library (Metin2Python::stdlib_path) when `stdlib_path` is empty, then starts
// the interpreter. "" on success, including when it was already running.
static godot::String start(const godot::String &stdlib_path);
static bool is_running();
// __DEBUG__, __COMMAND_LINE__ and system.py out of the packs. "" when system.py ran to the end.
static godot::String run_main_script(const godot::String &command_line);
// One line of Python in the same __main__ dictionary. "" when it ran.
static godot::String run_line(const godot::String &source);
static godot::String evaluate(const godot::String &expression);
// True while system.py is inside app.Loop(); ui_update() then runs one CPythonApplication::Process().
static bool is_app_looping();
static void set_ui_size(int width, int height);
static void ui_mouse_move(int x, int y);
static void ui_mouse_button(int button, bool pressed, int x, int y);
static void ui_mouse_wheel(int delta);
static void ui_key(int key, bool pressed);
// WM_CHAR (Unicode code point) and WM_KEYDOWN (Win32 VK code) for the 40250 IME.
static void ui_char(int codepoint);
static void ui_ime_key(int vkey);
static void ui_update();
static godot::Array ui_render_commands();
// Ordered UI commands with consecutive, unclipped font quads packed into triangle arrays.
static godot::Array ui_render_commands_batched();
static bool has_3d_draws();
// The pixels of a "mem:<id>@<revision>" image command (the CGraphicFontTexture glyph pages) as an
// RGBA8 Image; null when the texture is gone or its revision is still `known_revision`.
static godot::Ref<godot::Image> memory_texture(const godot::String &name, int64_t known_revision);
// The 3D draw calls of the last ui_update() (RenderCommands3D.h), one Dictionary each: "world",
// "view", "proj" (PackedFloat32Array, D3D row-vector layout), vertex arrays, "indices", "texture0"
// and the blend/depth/cull/lighting state.
static godot::Array render3d_draws();
static godot::Array audio_commands();
static godot::String current_map_name();
static void stop();
protected:
static void _bind_methods();
};
} // namespace mtgodot
-133
View File
@@ -1,133 +0,0 @@
#include "python_stdlib.h"
#include <godot_cpp/classes/dir_access.hpp>
#include <godot_cpp/classes/file_access.hpp>
#include <godot_cpp/classes/project_settings.hpp>
#include <godot_cpp/core/class_db.hpp>
#include <godot_cpp/variant/utility_functions.hpp>
#include <string>
#include <cstdio>
#ifdef MTGODOT_HAVE_PYTHON
#include "platform/ScriptLib/PythonHost.h"
#endif
using namespace godot;
namespace mtgodot {
namespace {
// Both ship in the PCK; the build copies them next to project.godot (extension/CMakeLists.txt).
constexpr const char *ZIP_RES = "res://python27.zip";
constexpr const char *SHA_RES = "res://python27.zip.sha256";
constexpr const char *ZIP_USER = "user://python27.zip";
constexpr const char *ZIP_USER_TMP = "user://python27.zip.part";
// std::string, not String: this lives at file scope and a godot::String's constructor would run at
// dlopen time, before the extension's bindings exist.
std::string g_last_error;
String fail(String *error, const String &text) {
g_last_error = text.utf8().get_data();
if (error) {
*error = text;
}
return String();
}
String expected_sha() {
if (!FileAccess::file_exists(SHA_RES)) {
return String();
}
return FileAccess::get_file_as_string(SHA_RES).strip_edges();
}
} // namespace
String stage_python_stdlib(String *error, bool force_stage) {
g_last_error.clear();
if (error) {
*error = String();
}
if (!FileAccess::file_exists(ZIP_RES)) {
return fail(error, String("no ") + ZIP_RES +
" (build with -DMTGODOT_EMBED_PYTHON=ON, which copies it into project/)");
}
ProjectSettings *settings = ProjectSettings::get_singleton();
// Desktop and the editor: res:// is a directory on disk, so CPython can open the file in place and
// nothing is copied. In an exported game globalize_path() returns a path next to the executable
// that does not exist, which is exactly the case the staging below is for.
const String in_place = settings->globalize_path(ZIP_RES);
if (!force_stage && !in_place.begins_with("res://")) {
// FileAccess may resolve an exported PCK entry through a relative globalized path. CPython
// uses fopen(), so only return the path when libc can open a real on-disk zip there.
if (FILE *file = std::fopen(in_place.utf8().get_data(), "rb")) {
std::fclose(file);
return in_place;
}
}
const String want = expected_sha();
if (want.is_empty()) {
return fail(error, String("no ") + SHA_RES + " beside the stdlib zip");
}
// An already-staged copy is reused only when it is the zip this build ships: a new version of the
// app brings a new digest, and a first start that was killed mid-copy leaves a short file.
if (FileAccess::file_exists(ZIP_USER) && FileAccess::get_sha256(ZIP_USER) == want) {
return settings->globalize_path(ZIP_USER);
}
const PackedByteArray data = FileAccess::get_file_as_bytes(ZIP_RES);
if (data.is_empty()) {
return fail(error, String("cannot read ") + ZIP_RES);
}
{
Ref<FileAccess> out = FileAccess::open(ZIP_USER_TMP, FileAccess::WRITE);
if (out.is_null()) {
return fail(error, String("cannot write ") + ZIP_USER_TMP + ": " +
itos((int) FileAccess::get_open_error()));
}
out->store_buffer(data);
out->close();
}
// Hash the file as written, not the buffer: a full disk reports itself here and not later as a
// zipimport failure three imports into system.py.
if (FileAccess::get_sha256(ZIP_USER_TMP) != want) {
DirAccess::remove_absolute(ZIP_USER_TMP);
return fail(error, String("staged ") + ZIP_USER + " does not match " + want);
}
if (FileAccess::file_exists(ZIP_USER)) {
DirAccess::remove_absolute(ZIP_USER);
}
if (DirAccess::rename_absolute(ZIP_USER_TMP, ZIP_USER) != OK) {
return fail(error, String("cannot rename ") + ZIP_USER_TMP + " to " + ZIP_USER);
}
UtilityFunctions::print("[python27] staged ", ZIP_USER, " (", data.size(), " bytes, sha256 ", want, ")");
return settings->globalize_path(ZIP_USER);
}
String Metin2Python::stdlib_path(bool force_stage) {
const String path = stage_python_stdlib(nullptr, force_stage);
#ifdef MTGODOT_HAVE_PYTHON
if (!path.is_empty()) {
PythonHost::SetDefaultStdLibPath(path.utf8().get_data());
}
#endif
return path;
}
String Metin2Python::last_error() {
return String::utf8(g_last_error.c_str());
}
void Metin2Python::_bind_methods() {
ClassDB::bind_static_method("Metin2Python", D_METHOD("stdlib_path", "force_stage"),
&Metin2Python::stdlib_path, DEFVAL(false));
ClassDB::bind_static_method("Metin2Python", D_METHOD("last_error"), &Metin2Python::last_error);
}
} // namespace mtgodot
-37
View File
@@ -1,37 +0,0 @@
#pragma once
// python_stdlib — where the embedded interpreter's standard library is on this platform
// (docs/PORT-PLAN.md 批次 2P step 3c).
//
// CPython opens python27.zip with its own stdio, so it needs a real filesystem path. That is free on
// the desktop, where res:// is a directory on disk, but on Android and iOS res:// is inside the PCK,
// which nothing outside Godot can read. There the zip is copied once into the sandbox (user://) and
// the copy is accepted only when it hashes to the digest built beside the zip, so an interrupted
// first start or a zip replaced by a new build re-stages instead of feeding zipimport a torn file.
//
// Only this file knows about res:// / user://: PythonHost (platform/ScriptLib) stays godot-free and
// is told the path, through PythonHost::SetDefaultStdLibPath.
#include <godot_cpp/classes/object.hpp>
#include <godot_cpp/variant/string.hpp>
namespace mtgodot {
// The real filesystem path of python27.zip, staging it out of res:// when res:// is not on disk.
// Empty when the zip is missing or the staged copy does not verify; the reason goes to *error.
// force_stage runs the mobile path even where res:// is readable, which is how it is tested.
godot::String stage_python_stdlib(godot::String *error = nullptr, bool force_stage = false);
class Metin2Python : public godot::Object {
GDCLASS(Metin2Python, godot::Object)
public:
// Returns the path and, when the embedded interpreter is built in, hands it to PythonHost so
// PythonHost::DefaultStdLibPath() answers with it from then on.
static godot::String stdlib_path(bool force_stage);
static godot::String last_error();
protected:
static void _bind_methods();
};
} // namespace mtgodot
-82
View File
@@ -1,82 +0,0 @@
#include "register_types.h"
#include <gdextension_interface.h>
#include <godot_cpp/core/defs.hpp>
#include <godot_cpp/godot.hpp>
#include "asset_io.h"
#include "metin2_anim.h"
#include "metin2_model.h"
#include "metin2_world.h"
#include "m2_material.h"
#include "net/m2_client.h"
#include "pack40250_node.h"
#include "proto/proto_node.h"
#include "python_host_node.h"
#include "python_stdlib.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;
// 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) {
if (p_level != MODULE_INITIALIZATION_LEVEL_SCENE) {
return;
}
fmt::set_file_reader(&mt_fmt_read_file);
GDREGISTER_CLASS(mtgodot::Metin2Model);
GDREGISTER_CLASS(mtgodot::Metin2AnimPlayer);
GDREGISTER_CLASS(mtgodot::Metin2World);
GDREGISTER_CLASS(mtgodot::M2Client);
GDREGISTER_CLASS(mtgodot::Metin2Proto);
GDREGISTER_CLASS(mtgodot::Metin2Pack);
GDREGISTER_CLASS(mtgodot::Metin2Python);
GDREGISTER_CLASS(mtgodot::Metin2PythonHost);
}
void uninitialize_mtgodot_module(ModuleInitializationLevel p_level) {
if (p_level == MODULE_INITIALIZATION_LEVEL_SCENE) {
fmt::set_file_reader(nullptr);
mtgodot::Metin2AnimPlayer::clear_clip_cache();
// CPythonApplication::Destroy / CResourceManager teardown releases
// model-owned decoded textures before Godot's ObjectDB is destroyed.
// Leaving this static Ref cache alive makes renderer shutdown report
// leaked ImageTexture objects after a real model/map scenario.
mtgodot::Metin2Model::clear_texture_cache();
mtgodot::cleanup_material_shaders();
mtgodot::cleanup_terrain_shader();
mtgodot::cleanup_tree_shader();
mtgodot::cleanup_static_object_cache();
mtgodot::cleanup_water_shader();
}
}
extern "C" {
GDExtensionBool GDE_EXPORT mtgodot_library_init(
GDExtensionInterfaceGetProcAddress p_get_proc_address,
const GDExtensionClassLibraryPtr p_library,
GDExtensionInitialization *r_initialization) {
godot::GDExtensionBinding::InitObject init_obj(p_get_proc_address, p_library, r_initialization);
init_obj.register_initializer(initialize_mtgodot_module);
init_obj.register_terminator(uninitialize_mtgodot_module);
init_obj.set_minimum_library_initialization_level(MODULE_INITIALIZATION_LEVEL_SCENE);
return init_obj.init();
}
}
-6
View File
@@ -1,6 +0,0 @@
#pragma once
#include <godot_cpp/core/class_db.hpp>
void initialize_mtgodot_module(godot::ModuleInitializationLevel p_level);
void uninitialize_mtgodot_module(godot::ModuleInitializationLevel p_level);
-163
View File
@@ -1,163 +0,0 @@
#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_DISABLED);
mat->set_diffuse_mode(StandardMaterial3D::DIFFUSE_LAMBERT_WRAP);
mat->set_specular_mode(StandardMaterial3D::SPECULAR_DISABLED);
mat->set_feature(StandardMaterial3D::FEATURE_BACKLIGHT, true);
mat->set_backlight(Color(0.2f, 0.2f, 0.2f));
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));
mat->set_feature(StandardMaterial3D::FEATURE_EMISSION, true);
mat->set_texture(StandardMaterial3D::TEXTURE_EMISSION, tex);
mat->set_emission(Color(1.0f, 1.0f, 1.0f));
mat->set_emission_energy_multiplier(0.18f);
}
return mat;
}
} // namespace
Ref<godot::ArrayMesh> get_static_mesh(const std::string &real_gr2_path,
const fmt::AssetResolver &res, StaticMeshCache &cache) {
auto it = cache.by_path.find(real_gr2_path);
if (it != cache.by_path.end())
return it->second;
Ref<godot::ArrayMesh> result; // invalid until success
gr2::LoadError err;
auto loaded = mtgodot::gr2_from_file(godot::String(real_gr2_path.c_str()), &err);
if (!loaded) {
++cache.failed;
UtilityFunctions::push_warning(String("[static] gr2 load failed: ") +
real_gr2_path.c_str() + " (" + err.message.c_str() + ")");
cache.by_path.emplace(real_gr2_path, result);
return result;
}
const gr2::FileInfo &fi = loaded->file_info();
// 贴图名 -> 渲染态(来自 libgr2 dump_materials 的名字/map 推断)
std::vector<gr2::MaterialInfo> mats = gr2::dump_materials(*loaded);
std::map<std::string, std::pair<bool, bool>> tex_state; // lower(tex) -> {alpha, two_sided}
for (const auto &m : mats) {
std::string k;
for (char c : m.diffuse_texture)
k += (char)std::tolower((unsigned char)c);
if (!k.empty())
tex_state[k] = {m.alpha_blend, m.two_sided};
}
std::vector<mtgodot::RenderPart> parts = mtgodot::build_parts(fi);
AABB bounds;
Ref<godot::ArrayMesh> mesh = mtgodot::build_mesh(fi, parts, /*flip_winding=*/false, bounds);
if (mesh.is_valid() && mesh->get_surface_count() > 0) {
for (int s = 0; s < mesh->get_surface_count() && s < (int)parts.size(); ++s) {
const mtgodot::RenderPart &rp = parts[s];
std::string tex;
if (rp.mesh >= 0 && rp.mesh < (int)fi.meshes.size()) {
const auto &mt = fi.meshes[rp.mesh].material_textures;
if (rp.mat_index >= 0 && rp.mat_index < (int)mt.size())
tex = mt[rp.mat_index];
else if (!mt.empty())
tex = mt[0];
}
bool alpha = false, two = false;
{
std::string k;
for (char c : tex)
k += (char)std::tolower((unsigned char)c);
auto f = tex_state.find(k);
if (f != tex_state.end()) {
alpha = f->second.first;
two = f->second.second;
}
}
mesh->surface_set_material(s, material_for(tex, alpha, two, res));
}
result = mesh;
++cache.loaded;
} else {
++cache.failed;
}
cache.by_path.emplace(real_gr2_path, result);
return result;
}
void cleanup_static_object_cache() { g_dds_cache.clear(); }
} // namespace mtgodot
-35
View File
@@ -1,35 +0,0 @@
#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
-264
View File
@@ -1,264 +0,0 @@
#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 <chrono>
#include <cstdio>
#include <cstdlib>
#include <unordered_map>
#include <utility>
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;
std::unordered_map<std::string, std::pair<int, int>> g_layer_dimensions;
Ref<godot::Image> layer_image(const std::string &real_path, int size,
std::unordered_map<std::string, mtgodot::Image> &decoded_sources) {
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;
auto decoded = decoded_sources.find(real_path);
if (decoded != decoded_sources.end()) {
d = std::move(decoded->second);
decoded_sources.erase(decoded);
} else {
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(那时引擎已析构)
g_layer_dimensions.clear();
}
Ref<ShaderMaterial> build_chunk_terrain_material(const fmt::SplatSet &splat,
const fmt::TextureSet &tset, const fmt::AssetResolver &res,
const String &shadowmap_path) {
const bool profile = std::getenv("MT_PROFILE_MAP") != nullptr;
const auto now = [] { return std::chrono::steady_clock::now(); };
const auto start = now();
// Texture2DArray 要求各 slice 同尺寸 —— 取用到的图层里的最大源边长(上限 1024),
// 只放大不缩小最大源,避免把 512² 地表贴图硬降采样(PARITY-GAP §3.4)。
int src_max = 256;
// Keep newly decoded sources only until the image pass consumes them. The size pass and image pass
// used to decode the same DDS twice on its first use.
std::unordered_map<std::string, mtgodot::Image> decoded_sources;
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;
auto it = g_layer_dimensions.find(rp);
if (it == g_layer_dimensions.end()) {
mtgodot::Image d = mtgodot::dds_from_file(godot::String(rp.c_str()));
it = g_layer_dimensions.emplace(rp, d.ok() ? std::make_pair(int(d.w), int(d.h))
: std::make_pair(0, 0)).first;
decoded_sources.emplace(rp, std::move(d));
}
src_max = std::max(src_max, std::max(it->second.first, it->second.second));
}
}
const int LSIZE = std::min(1024, src_max);
const auto dimensions_done = now();
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, decoded_sources);
// 原客户端 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);
}
const auto images_done = now();
Ref<Texture2DArray> arr;
arr.instantiate();
arr->create_from_images(imgs);
const auto array_done = now();
// 权重贴图: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);
}
const auto weights_done = now();
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);
}
const auto shader_done = now();
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);
}
}
if (profile) {
const auto ms = [](auto a, auto b) { return std::chrono::duration<double, std::milli>(b - a).count(); };
std::fprintf(stderr, "MATERIAL_PROFILE dimensions=%.3f images=%.3f array=%.3f weights=%.3f shader=%.3f shadow=%.3f total=%.3f layers=%d size=%d\n",
ms(start, dimensions_done), ms(dimensions_done, images_done), ms(images_done, array_done),
ms(array_done, weights_done), ms(weights_done, shader_done), ms(shader_done, now()),
ms(start, now()), n, LSIZE);
}
return mat;
}
} // namespace mtgodot
-32
View File
@@ -1,32 +0,0 @@
#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
-76
View File
@@ -1,76 +0,0 @@
#include "texture_util.h"
#include <cstdlib>
#include <cstring>
#include <godot_cpp/classes/os.hpp>
#include <godot_cpp/core/class_db.hpp>
#include <godot_cpp/variant/packed_byte_array.hpp>
#include <godot_cpp/variant/utility_functions.hpp>
using namespace godot;
namespace mtgodot {
namespace {
int g_state = -1; // -1 = uninit, 0 = off, 1 = on
bool g_warned = false;
void lazy_init() {
if (g_state != -1) {
return;
}
if (const char *e = std::getenv("MTGODOT_TEXCOMP")) {
g_state = (e[0] == '1') ? 1 : 0;
return;
}
// No explicit override: on for mobile targets, off for desktop (keeps the
// Phase-1 parity path byte-identical).
OS *os = OS::get_singleton();
g_state = (os && os->has_feature("mobile")) ? 1 : 0;
}
} // namespace
bool texcomp_enabled() {
lazy_init();
return g_state == 1;
}
void texcomp_set_enabled(bool on) {
g_state = on ? 1 : 0;
}
Ref<ImageTexture> make_color_texture(int w, int h, const uint8_t *rgba, size_t len,
bool mipmaps, TexUse use) {
if (w <= 0 || h <= 0 || rgba == nullptr || len < size_t(w) * size_t(h) * 4) {
return Ref<ImageTexture>();
}
PackedByteArray bytes;
bytes.resize(int64_t(w) * h * 4);
std::memcpy(bytes.ptrw(), rgba, size_t(w) * size_t(h) * 4);
Ref<Image> img = Image::create_from_data(w, h, false, Image::FORMAT_RGBA8, bytes);
if (img.is_null()) {
return Ref<ImageTexture>();
}
if (mipmaps) {
img->generate_mipmaps();
}
if (use == TexUse::COLOR && texcomp_enabled()) {
// ASTC 8x8: ~2 bpp vs 32 for RGBA8. GENERIC source hint (sRGB colour).
Error err = img->compress(Image::COMPRESS_ASTC, Image::COMPRESS_SOURCE_GENERIC,
Image::ASTC_FORMAT_8x8);
if (err != OK && !g_warned) {
g_warned = true;
UtilityFunctions::push_warning(
"mtgodot: runtime ASTC compression unavailable, using RGBA8");
}
}
return ImageTexture::create_from_image(img);
}
} // namespace mtgodot
-34
View File
@@ -1,34 +0,0 @@
// 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
-542
View File
@@ -1,542 +0,0 @@
#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/file_access.hpp>
#include <godot_cpp/classes/json.hpp>
#include <godot_cpp/classes/gltf_document.hpp>
#include <godot_cpp/classes/gltf_state.hpp>
#include <godot_cpp/classes/gltf_mesh.hpp>
#include <godot_cpp/classes/importer_mesh.hpp>
#include <godot_cpp/variant/utility_functions.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));
// MapUtil.cpp initializes fWindStrength to 0.2 and fWindRandom to 0;
// Environment_Load does not read a wind token from .msenv.
leaf->set_shader_parameter("wind_strength", 0.2f);
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);
}
static Ref<ArrayMesh> load_native_tree(const std::string &treefile, const fmt::AssetResolver &resolver) {
const String source(resolver.resolve(treefile, nullptr).c_str());
if (source.is_empty()) return {};
const String directory = String(resolver.assets_root.c_str()).path_join("TreeGeometry");
const String manifest_path = directory.path_join("manifest.json");
if (!FileAccess::file_exists(manifest_path)) return {};
const Variant parsed = JSON::parse_string(FileAccess::get_file_as_string(manifest_path));
if (parsed.get_type() != Variant::DICTIONARY) return {};
const Dictionary manifest = parsed;
if (int(manifest.get("schema_version", 0)) != 1 || String(manifest.get("coordinates", "")) != "godot_y_up_meters") return {};
const Dictionary trees = manifest.get("trees", Dictionary());
const String source_hash = FileAccess::get_sha256(source);
if (!trees.has(source_hash)) return {};
const Dictionary record = trees[source_hash];
const String filename = record.get("glb", "");
if (filename != source_hash + String(".glb")) return {};
const String path = directory.path_join(filename);
if (!FileAccess::file_exists(path) || FileAccess::get_sha256(path) != String(record.get("glb_sha256", ""))) return {};
Ref<GLTFDocument> document;
document.instantiate();
Ref<GLTFState> state;
state.instantiate();
if (document->append_from_file(path, state) != OK || state->get_meshes().size() != 1) return {};
Ref<GLTFMesh> gltf_mesh = state->get_meshes()[0];
if (gltf_mesh.is_null() || gltf_mesh->get_mesh().is_null()) return {};
Ref<ArrayMesh> mesh = gltf_mesh->get_mesh()->get_mesh();
if (mesh.is_null() || mesh->get_surface_count() == 0) return {};
mesh->set_meta("tree_geometry", "native_spt");
mesh->set_meta("source_sha256", source_hash);
mesh->set_meta("glb_sha256", record.get("glb_sha256", ""));
mesh->set_meta("leaf_mode", "static_extracted_cards");
// GLB vertices already use Godot metres. Never impose the proxy's 12m height.
return mesh;
}
Ref<ArrayMesh> get_tree_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;
Ref<ArrayMesh> native = load_native_tree(treefile, resolver);
if (native.is_valid()) {
g_tree_mesh_cache.emplace(key, native);
return native;
}
UtilityFunctions::push_warning(String("Tree native geometry unavailable or hash mismatch; proxy: ") + String(treefile.c_str()));
const TreeTextures textures = resolve_tree_textures(treefile, resolver);
Ref<ArrayMesh> mesh = build_proxy_impl(treefile, height_m, textures);
mesh->set_meta("tree_geometry", "proxy");
g_tree_mesh_cache.emplace(key, mesh);
return mesh;
}
} // namespace mtgodot
-35
View File
@@ -1,35 +0,0 @@
#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;
}
// Native trees use verified offline GLBs; `.spt` is never linked to a proprietary
// runtime on macOS. Deterministic procedural geometry remains a reported fallback.
// 一份共享 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);
// Cached per treefile for MultiMesh use. Missing or mismatched native resources
// use a proxy with explicit metadata and a warning; package acceptance rejects it.
// Prefer hash-matched native GLB geometry in assets/TreeGeometry. The procedural
// implementation is an explicitly reported fallback for missing/invalid trees.
godot::Ref<godot::ArrayMesh> get_tree_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
-218
View File
@@ -1,218 +0,0 @@
#include "water_builder.h"
#include "water_motion.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/classes/time.hpp>
#include <godot_cpp/variant/utility_functions.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, unshaded;
uniform sampler2DArray frames : source_color, filter_linear_mipmap, repeat_enable;
uniform float uv_per_meter = 0.125; // 1/8m,= 原客户端 1/(CELLSCALE*4)
uniform float height_offset = 0.0; // Shared reference water translation, metres.
void vertex() {
VERTEX.y += height_offset;
}
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;
// MapOutdoorWater selects texture RGB and vertex diffuse alpha directly.
// Do not introduce PBR lighting, invented tint or view-dependent opacity.
ALBEDO = tex;
ALPHA = COLOR.a;
}
)";
Ref<Shader> g_water_shader;
Ref<ShaderMaterial> g_water_mat; // 30 帧数组只建一次
WaterMotion g_water_motion;
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();
g_water_motion = WaterMotion{};
}
void update_water_animation() {
if (g_water_mat.is_null()) return;
const uint64_t now = Time::get_singleton()->get_ticks_msec();
uint64_t duration = 1000;
double depth = 0;
if (g_water_motion.initialized && now - g_water_motion.start_ms > g_water_motion.duration_ms) {
duration = UtilityFunctions::randi_range(1000, 3000);
if (g_water_motion.end == 0) depth = UtilityFunctions::randi_range(0, 15);
}
g_water_mat->set_shader_parameter("height_offset", g_water_motion.sample(now, duration, depth));
}
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;
// AreaTerrain uses RAW height differences, before HeightScale, with
// OpaqueWaterDepth=400 and clamps at 80% (not a 12% shoreline floor).
return water_depth_alpha(depth_cm, height_scale);
};
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;
// Keep each cell's four shoreline samples. Merging a whole row
// discarded interior terrain peaks and bridged dry banks with water.
++x;
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);
// AreaTerrain skips the quad when every diffuse alpha is zero.
if (aLL == 0 && aLR == 0 && aUR == 0 && aUL == 0)
continue;
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)));
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
-33
View File
@@ -1,33 +0,0 @@
#pragma once
#include "water_depth.h"
#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();
void update_water_animation();
} // namespace mtgodot
-12
View File
@@ -1,12 +0,0 @@
#pragma once
#include <cmath>
namespace mtgodot {
// 40250 MapOutdoor.cpp / AreaTerrain.cpp. Arguments in scaled centimetres.
inline float water_depth_alpha(double depth_cm, double height_scale) {
if (!std::isfinite(depth_cm) || !std::isfinite(height_scale) ||
height_scale <= 0.0 || depth_cm <= 0.0) return 0.0f;
double alpha = depth_cm / (400.0 * height_scale);
return static_cast<float>(alpha > 0.8 ? 0.8 : alpha);
}
}
-24
View File
@@ -1,24 +0,0 @@
#pragma once
#include <algorithm>
#include <cstdint>
namespace mtgodot {
// MapOutdoorWater: one shared vertical translation, alternating a random
// 0..-15cm endpoint and zero, linearly interpolated over 1000..3000ms.
struct WaterMotion {
uint64_t start_ms = 0, duration_ms = 300;
double begin = 0, end = 0, current = 0;
bool initialized = false;
double sample(uint64_t now, uint64_t next_duration, double next_depth_cm) {
if (!initialized) { start_ms = now; initialized = true; }
if (now - start_ms > duration_ms) {
begin = current;
end = end == 0 ? -std::clamp(next_depth_cm, 0.0, 15.0) * 0.01 : 0;
start_ms = now;
duration_ms = std::clamp<uint64_t>(next_duration, 1000, 3000);
}
current = begin + (end - begin) * double(now - start_ms) / double(duration_ms);
return current;
}
};
}
+2 -2
View File
@@ -13,8 +13,8 @@ Bytes header(uint32_t bits, uint32_t r, uint32_t g, uint32_t b, uint32_t a, size
put(out, 92, r); put(out, 96, g); put(out, 100, b); put(out, 104, a); put(out, 92, r); put(out, 96, g); put(out, 100, b); put(out, 104, a);
return out; return out;
} }
mtgodot::Image decode(const Bytes& b) { return mtgodot::load_dds(b.data(), b.size()); } mtimage::Image decode(const Bytes& b) { return mtimage::load_dds(b.data(), b.size()); }
void pixel(const mtgodot::Image& im, size_t n, uint8_t r, uint8_t g, uint8_t b, uint8_t a) { void pixel(const mtimage::Image& im, size_t n, uint8_t r, uint8_t g, uint8_t b, uint8_t a) {
assert(im.ok()); assert(im.rgba[n*4] == r && im.rgba[n*4+1] == g && im.rgba[n*4+2] == b && im.rgba[n*4+3] == a); assert(im.ok()); assert(im.rgba[n*4] == r && im.rgba[n*4+1] == g && im.rgba[n*4+2] == b && im.rgba[n*4+3] == a);
} }
int main() { int main() {
-17
View File
@@ -1,17 +0,0 @@
#include "water_depth.h"
#include <cassert>
#include <cstdio>
#include <limits>
#include <initializer_list>
int main() {
for (double scale : {0.25, 0.5, 1.0, 2.0}) {
assert(mtgodot::water_depth_alpha(-1, scale) == 0);
assert(mtgodot::water_depth_alpha(0, scale) == 0);
assert(std::abs(mtgodot::water_depth_alpha(100 * scale, scale) - 0.25) < 1e-6);
assert(std::abs(mtgodot::water_depth_alpha(320 * scale, scale) - 0.8) < 1e-6);
assert(std::abs(mtgodot::water_depth_alpha(1000 * scale, scale) - 0.8) < 1e-6);
}
assert(mtgodot::water_depth_alpha(100, 0) == 0);
assert(mtgodot::water_depth_alpha(std::numeric_limits<double>::quiet_NaN(), 1) == 0);
std::puts("PASS: water depth reference (raw depth, shoreline, cap, height scale)");
}
-21
View File
@@ -1,21 +0,0 @@
#include "water_motion.h"
#include <cassert>
#include <cmath>
#include <cstdio>
int main() {
mtgodot::WaterMotion m;
assert(m.sample(100, 2000, 15) == 0);
assert(m.sample(400, 2000, 15) == 0);
assert(m.sample(401, 2000, 15) == 0);
assert(std::abs(m.sample(1401, 2000, 15) + .075) < 1e-9);
assert(std::abs(m.sample(2401, 2000, 15) + .15) < 1e-9);
assert(std::abs(m.sample(2402, 1000, 15) + .15) < 1e-9);
assert(std::abs(m.sample(2902, 1000, 15) + .075) < 1e-9);
assert(m.sample(3402, 1000, 15) == 0);
// Large frame gaps start at the last rendered height, without overshoot.
for (uint64_t t = 3403; t < 200000; t += 137) {
const double y = m.sample(t, 1700, 9);
assert(y <= 0 && y >= -.15);
}
std::puts("PASS: shared linear water motion, endpoints, timing, no overshoot");
}
+5 -5
View File
@@ -1,9 +1,9 @@
# extension/third_party — vendored native dependencies for the GDExtension. # Vendored native dependencies for the 40250 port and SDL/Vulkan client.
# #
# Phase 1 (macOS) used Homebrew for libsodium / libzstd / liblzo2. Those don't # 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 # 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 # 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: # pinned here and produce static libraries for port_platform:
# #
# sodium <- libsodium-cmake submodule (wraps jedisct1/libsodium) # sodium <- libsodium-cmake submodule (wraps jedisct1/libsodium)
# libzstd_static <- facebook/zstd submodule, its own build/cmake project # libzstd_static <- facebook/zstd submodule, its own build/cmake project
@@ -13,7 +13,7 @@
# GPL: fine for this internal, non-published project — same footing as # 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. # 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. # Android links these static libraries into libmain.so, so they must be PIC.
set(CMAKE_POSITION_INDEPENDENT_CODE ON) set(CMAKE_POSITION_INDEPENDENT_CODE ON)
# --- libsodium ------------------------------------------------------------------ # --- libsodium ------------------------------------------------------------------
@@ -57,7 +57,7 @@ if(WIN32)
else() else()
set(MT_EMBED_PYTHON_DEFAULT ON) set(MT_EMBED_PYTHON_DEFAULT ON)
endif() endif()
option(MTGODOT_EMBED_PYTHON "Build the embedded CPython 2.7.18 static library" ${MT_EMBED_PYTHON_DEFAULT}) option(MT_EMBED_PYTHON "Build the embedded CPython 2.7.18 static library" ${MT_EMBED_PYTHON_DEFAULT})
if(MTGODOT_EMBED_PYTHON) if(MT_EMBED_PYTHON)
add_subdirectory(cpython-2.7.18) add_subdirectory(cpython-2.7.18)
endif() endif()
-29
View File
@@ -1,29 +0,0 @@
#!/usr/bin/env bash
# Generate the Android debug keystore Godot's editor settings point at:
# ~/Library/Application Support/Godot/keystores/debug.keystore
# (editor_settings-4.7.tres: export/android/debug_keystore, pass "android").
# Standard well-known debug credentials — never used for release signing.
set -euo pipefail
KS_DIR="$HOME/Library/Application Support/Godot/keystores"
KS="$KS_DIR/debug.keystore"
if [ -f "$KS" ]; then
echo "already exists: $KS"
exit 0
fi
KEYTOOL="${JAVA_HOME:+$JAVA_HOME/bin/}keytool"
command -v "$KEYTOOL" >/dev/null 2>&1 || KEYTOOL=keytool
mkdir -p "$KS_DIR"
"$KEYTOOL" -genkeypair -v \
-keystore "$KS" \
-storepass android -keypass android \
-alias androiddebugkey \
-keyalg RSA -keysize 2048 -validity 10000 \
-dname "CN=Android Debug,O=Android,C=US"
echo
echo "created: $KS"
echo "Godot editor settings already reference it (export/android/debug_keystore)."
-8
View File
@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
</dict>
</plist>
+5 -14
View File
@@ -1,6 +1,6 @@
# Native Vulkan renderer prototype # Native Vulkan client
This is the standalone renderer path for the 40250 `Render3DDraw` and `UIRenderCommand` command streams, plus direct `--live-client` execution of the ported 40250 client (`PythonBoot`). SDL3 owns the window and input forwarding; Vulkan owns the swapchain (`FIFO` or `IMMEDIATE`/`MAILBOX`), `VK_FORMAT_D32_SFLOAT` depth buffer, `VK_QUERY_TYPE_TIMESTAMP` hardware GPU timer, key-indexed persistent vertex/index buffers, GPU skeletal skinning bone palette SSBO (`set = 1, binding = 0`), DDS/TGA/`mem:` glyph-page texture sampler descriptors, fixed-function 3D + 2D UI state pipelines, and draw submission. On macOS, the Vulkan loader uses MoltenVK over Metal. The existing Godot client remains the default playable path. This is the standalone renderer path for the 40250 `Render3DDraw` and `UIRenderCommand` command streams, plus direct `--live-client` execution of the ported 40250 client (`PythonBoot`). SDL3 owns the window and input forwarding; Vulkan owns the swapchain (`FIFO` or `IMMEDIATE`/`MAILBOX`), `VK_FORMAT_D32_SFLOAT` depth buffer, `VK_QUERY_TYPE_TIMESTAMP` hardware GPU timer, key-indexed persistent vertex/index buffers, GPU skeletal skinning bone palette SSBO (`set = 1, binding = 0`), DDS/TGA/`mem:` glyph-page texture sampler descriptors, fixed-function 3D + 2D UI state pipelines, and draw submission. On macOS, the Vulkan loader uses MoltenVK over Metal. This is the active macOS and Android client path.
## Build and run on macOS ## Build and run on macOS
@@ -9,18 +9,9 @@ Install Vulkan headers/loader, MoltenVK, SDL3 and `glslc` (shaderc). With Homebr
```sh ```sh
brew install vulkan-headers vulkan-loader molten-vk sdl3 shaderc brew install vulkan-headers vulkan-loader molten-vk sdl3 shaderc
# 1. Standalone replay build (without port_platform) # Optimized Release build (includes port_platform, embedded Python and FakeLoginServer)
cmake -S . -B build-native-render -DMTGODOT_BUILD_EXTENSION=OFF \ cmake -S . -B build-release -DCMAKE_BUILD_TYPE=Release -DMT_BUILD_NATIVE_RENDER=ON \
-DMT_BUILD_NATIVE_RENDER_PROTOTYPE=ON -DCMAKE_PREFIX_PATH=/opt/homebrew -DMT_EMBED_PYTHON=ON -DCMAKE_PREFIX_PATH=/opt/homebrew
cmake --build build-native-render --target mt_native_render -j8
# 2. Full live-client build (links port_platform + mtpython + FakeLoginServer)
cmake -S . -B build -DMT_BUILD_NATIVE_RENDER_PROTOTYPE=ON -DCMAKE_PREFIX_PATH=/opt/homebrew
cmake --build build --target mtgodot mt_native_render port_fake_login_server -j8
# 3. Optimized Release (-O3) live-client build
cmake -S . -B build-release -DCMAKE_BUILD_TYPE=Release -DMT_BUILD_NATIVE_RENDER_PROTOTYPE=ON \
-DMTGODOT_EMBED_PYTHON=ON -DCMAKE_PREFIX_PATH=/opt/homebrew
cmake --build build-release --target mt_native_render -j8 cmake --build build-release --target mt_native_render -j8
``` ```
+138 -16
View File
@@ -9,6 +9,7 @@
#include "platform/PackBackend.h" #include "platform/PackBackend.h"
#include "platform/ScriptLib/PythonBoot.h" #include "platform/ScriptLib/PythonBoot.h"
#include "platform/UserInterface/ServerClock.h" #include "platform/UserInterface/ServerClock.h"
#include "platform/EterBase/TraceErrorObserver.h"
#include "../tests/port_login_flow_server.h" #include "../tests/port_login_flow_server.h"
#include <thread> #include <thread>
#endif #endif
@@ -23,6 +24,7 @@
#ifdef __APPLE__ #ifdef __APPLE__
#include <AudioToolbox/AudioToolbox.h> #include <AudioToolbox/AudioToolbox.h>
#include <TargetConditionals.h>
#endif #endif
#include <algorithm> #include <algorithm>
@@ -230,8 +232,8 @@ std::uint64_t geometry_hash(const Render3DDraw& draw) {
return hash_bytes(hash, draw.indices.data(), draw.indices.size() * sizeof(std::uint32_t)); return hash_bytes(hash, draw.indices.data(), draw.indices.size() * sizeof(std::uint32_t));
} }
mtgodot::Image decode_tga(const std::uint8_t* data, std::size_t size) { mtimage::Image decode_tga(const std::uint8_t* data, std::size_t size) {
mtgodot::Image out; mtimage::Image out;
if (size < 18) return out; if (size < 18) return out;
const std::uint8_t id_len = data[0]; const std::uint8_t id_len = data[0];
const std::uint8_t cmap_type = data[1]; const std::uint8_t cmap_type = data[1];
@@ -306,7 +308,7 @@ mtgodot::Image decode_tga(const std::uint8_t* data, std::size_t size) {
return out; return out;
} }
mtgodot::Image decode_texture_bytes(const std::uint8_t* data, std::size_t size) { mtimage::Image decode_texture_bytes(const std::uint8_t* data, std::size_t size) {
if (!data || size < 12) return {}; if (!data || size < 12) return {};
if (data[0] == 'M' && data[1] == 'T' && data[2] == 'R' && data[3] == 'A') { if (data[0] == 'M' && data[1] == 'T' && data[2] == 'R' && data[3] == 'A') {
std::uint32_t w = 0, h = 0; std::uint32_t w = 0, h = 0;
@@ -314,7 +316,7 @@ mtgodot::Image decode_texture_bytes(const std::uint8_t* data, std::size_t size)
std::memcpy(&h, data + 8, 4); std::memcpy(&h, data + 8, 4);
const std::size_t bytes = std::size_t(w) * std::size_t(h) * 4; const std::size_t bytes = std::size_t(w) * std::size_t(h) * 4;
if (w > 0 && h > 0 && w <= 4096 && h <= 4096 && size == 12 + bytes) { if (w > 0 && h > 0 && w <= 4096 && h <= 4096 && size == 12 + bytes) {
mtgodot::Image out; mtimage::Image out;
out.w = static_cast<std::uint16_t>(w); out.w = static_cast<std::uint16_t>(w);
out.h = static_cast<std::uint16_t>(h); out.h = static_cast<std::uint16_t>(h);
out.rgba.assign(data + 12, data + 12 + bytes); out.rgba.assign(data + 12, data + 12 + bytes);
@@ -323,14 +325,14 @@ mtgodot::Image decode_texture_bytes(const std::uint8_t* data, std::size_t size)
return {}; return {};
} }
if (data[0] == 'D' && data[1] == 'D' && data[2] == 'S' && data[3] == ' ') if (data[0] == 'D' && data[1] == 'D' && data[2] == 'S' && data[3] == ' ')
return mtgodot::load_dds(data, size); return mtimage::load_dds(data, size);
auto tga = decode_tga(data, size); auto tga = decode_tga(data, size);
if (tga.ok()) return tga; if (tga.ok()) return tga;
if (size > static_cast<std::size_t>(INT_MAX)) return {}; if (size > static_cast<std::size_t>(INT_MAX)) return {};
int w = 0, h = 0, channels = 0; int w = 0, h = 0, channels = 0;
stbi_uc* pixels = stbi_load_from_memory(data, static_cast<int>(size), &w, &h, &channels, 4); stbi_uc* pixels = stbi_load_from_memory(data, static_cast<int>(size), &w, &h, &channels, 4);
if (pixels && w > 0 && h > 0 && w <= 4096 && h <= 4096) { if (pixels && w > 0 && h > 0 && w <= 4096 && h <= 4096) {
mtgodot::Image out; mtimage::Image out;
out.w = static_cast<std::uint16_t>(w); out.w = static_cast<std::uint16_t>(w);
out.h = static_cast<std::uint16_t>(h); out.h = static_cast<std::uint16_t>(h);
out.rgba.assign(pixels, pixels + std::size_t(w) * std::size_t(h) * 4); out.rgba.assign(pixels, pixels + std::size_t(w) * std::size_t(h) * 4);
@@ -847,6 +849,7 @@ public:
} }
#endif #endif
if (!SDL_Init(SDL_INIT_VIDEO)) throw std::runtime_error(SDL_GetError()); if (!SDL_Init(SDL_INIT_VIDEO)) throw std::runtime_error(SDL_GetError());
SDL_SetHint(SDL_HINT_ORIENTATIONS, "LandscapeLeft LandscapeRight");
window_ = SDL_CreateWindow( window_ = SDL_CreateWindow(
"Metin2 Native Vulkan Client", "Metin2 Native Vulkan Client",
std::max(init_width, 320), std::max(init_width, 320),
@@ -979,6 +982,7 @@ public:
screenshot_path_ = std::move(path); screenshot_path_ = std::move(path);
screenshot_frame_ = frame; screenshot_frame_ = frame;
} }
std::uint64_t frame_number() const { return frame_number_; }
int msaa_samples() const { return int(samples_); } int msaa_samples() const { return int(samples_); }
std::uint32_t width() const { return extent_.width; } std::uint32_t width() const { return extent_.width; }
@@ -986,11 +990,19 @@ public:
std::uint32_t logical_width() const { std::uint32_t logical_width() const {
int w = 0, h = 0; int w = 0, h = 0;
if (window_) SDL_GetWindowSize(window_, &w, &h); if (window_) SDL_GetWindowSize(window_, &w, &h);
#ifdef __ANDROID__
// Keep the 40250 UI at the same height as the macOS 1280x800 window,
// while retaining the device aspect ratio on wider phone displays.
if (w > 0 && h > 0) return std::max(1, int(std::lround(double(w) * 800.0 / h)));
#endif
return w > 0 ? static_cast<std::uint32_t>(w) : extent_.width; return w > 0 ? static_cast<std::uint32_t>(w) : extent_.width;
} }
std::uint32_t logical_height() const { std::uint32_t logical_height() const {
int w = 0, h = 0; int w = 0, h = 0;
if (window_) SDL_GetWindowSize(window_, &w, &h); if (window_) SDL_GetWindowSize(window_, &w, &h);
#ifdef __ANDROID__
if (w > 0 && h > 0) return 800;
#endif
return h > 0 ? static_cast<std::uint32_t>(h) : extent_.height; return h > 0 ? static_cast<std::uint32_t>(h) : extent_.height;
} }
@@ -1192,10 +1204,10 @@ public:
if (event.type == SDL_EVENT_WINDOW_RESIZED || event.type == SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED) { if (event.type == SDL_EVENT_WINDOW_RESIZED || event.type == SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED) {
int ew = 0, eh = 0; int ew = 0, eh = 0;
SDL_GetWindowSize(window_, &ew, &eh); SDL_GetWindowSize(window_, &ew, &eh);
touch_controller.update_screen_size(ew, eh); touch_controller.update_screen_size(int(logical_width()), int(logical_height()));
#ifdef MT_NATIVE_HAS_LIVE_CLIENT #ifdef MT_NATIVE_HAS_LIVE_CLIENT
if (forward_to_live_client && ew > 0 && eh > 0) { if (forward_to_live_client && ew > 0 && eh > 0) {
PythonBoot::SetUISize(ew, eh); PythonBoot::SetUISize(int(logical_width()), int(logical_height()));
} }
#endif #endif
recreate_swapchain(); recreate_swapchain();
@@ -1658,9 +1670,12 @@ public:
present.waitSemaphoreCount = 1; present.pWaitSemaphores = &rendered_; present.waitSemaphoreCount = 1; present.pWaitSemaphores = &rendered_;
present.swapchainCount = 1; present.pSwapchains = &swapchain_; present.pImageIndices = &image_index; present.swapchainCount = 1; present.pSwapchains = &swapchain_; present.pImageIndices = &image_index;
const auto presented = vkQueuePresentKHR(queue_, &present); const auto presented = vkQueuePresentKHR(queue_, &present);
if (presented == VK_ERROR_OUT_OF_DATE_KHR || presented == VK_SUBOPTIMAL_KHR) { // An Android surface can continuously report SUBOPTIMAL while the app deliberately
// uses the identity transform. The image is still presentable; rebuilding every frame
// stalls rendering without changing that status.
if (presented == VK_ERROR_OUT_OF_DATE_KHR) {
recreate_swapchain(); recreate_swapchain();
} else if (presented != VK_SUCCESS) { } else if (presented != VK_SUCCESS && presented != VK_SUBOPTIMAL_KHR) {
check(presented, "vkQueuePresentKHR"); check(presented, "vkQueuePresentKHR");
} }
const auto presented_at = std::chrono::steady_clock::now(); const auto presented_at = std::chrono::steady_clock::now();
@@ -2027,7 +2042,17 @@ private:
info.imageFormat = format.format; info.imageColorSpace = format.colorSpace; info.imageExtent = extent_; info.imageFormat = format.format; info.imageColorSpace = format.colorSpace; info.imageExtent = extent_;
info.imageArrayLayers = 1; info.imageUsage = VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT | info.imageArrayLayers = 1; info.imageUsage = VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT |
(capabilities.supportedUsageFlags & VK_IMAGE_USAGE_TRANSFER_SRC_BIT); (capabilities.supportedUsageFlags & VK_IMAGE_USAGE_TRANSFER_SRC_BIT);
info.imageSharingMode = VK_SHARING_MODE_EXCLUSIVE; info.preTransform = capabilities.currentTransform; info.imageSharingMode = VK_SHARING_MODE_EXCLUSIVE;
// Android surfaces can report a quarter-turn transform even when SDL's window is
// already landscape. Rendering in SDL window coordinates and requesting that transform
// again rotates the entire game image on presentation.
info.preTransform = (capabilities.supportedTransforms & VK_SURFACE_TRANSFORM_IDENTITY_BIT_KHR)
? VK_SURFACE_TRANSFORM_IDENTITY_BIT_KHR : capabilities.currentTransform;
#ifdef __ANDROID__
SDL_Log("swapchain: window=%dx%d extent=%ux%u currentTransform=%u preTransform=%u",
width, height, extent_.width, extent_.height,
unsigned(capabilities.currentTransform), unsigned(info.preTransform));
#endif
info.compositeAlpha = VK_COMPOSITE_ALPHA_OPAQUE_BIT_KHR; info.presentMode = present_mode_; info.compositeAlpha = VK_COMPOSITE_ALPHA_OPAQUE_BIT_KHR; info.presentMode = present_mode_;
info.clipped = VK_TRUE; info.clipped = VK_TRUE;
check(vkCreateSwapchainKHR(device_, &info, nullptr, &swapchain_), "vkCreateSwapchainKHR"); check(vkCreateSwapchainKHR(device_, &info, nullptr, &swapchain_), "vkCreateSwapchainKHR");
@@ -2617,7 +2642,7 @@ private:
} }
it->second.last_decode_attempt_frame = frame_number_; it->second.last_decode_attempt_frame = frame_number_;
mtgodot::Image decoded; mtimage::Image decoded;
if (auto raw = capture_textures.find(texture_name); raw != capture_textures.end() && !raw->second.empty()) { if (auto raw = capture_textures.find(texture_name); raw != capture_textures.end() && !raw->second.empty()) {
decoded = decode_texture_bytes(raw->second.data(), raw->second.size()); decoded = decode_texture_bytes(raw->second.data(), raw->second.size());
} }
@@ -3187,6 +3212,32 @@ bool py_eval_true(const char* expr) {
return PythonBoot::Evaluate(expr, &res, &err) && (res == "True" || res == "1"); return PythonBoot::Evaluate(expr, &res, &err) && (res == "True" || res == "1");
} }
#ifdef __ANDROID__
bool hide_mobile_taskbar_quickslots() {
return py_exec(
"_mobile_taskbar = _stream.curPhaseWindow.interface.wndTaskBar\n"
"for _mobile_quickslot in _mobile_taskbar.quickslot:\n"
" _mobile_quickslot.Hide()\n"
"_mobile_taskbar.GetChild('QuickSlotBoard').Hide()\n");
}
#endif
#if defined(__APPLE__) && TARGET_OS_OSX
bool configure_macos_numeric_quickslots() {
return py_exec(
"_mac_game = _stream.curPhaseWindow\n"
"_mac_game.onPressKeyDict[app.DIK_5] = lambda: _mac_game._GameWindow__PressQuickSlot(4)\n"
"_mac_game.onPressKeyDict[app.DIK_6] = lambda: _mac_game._GameWindow__PressQuickSlot(5)\n"
"_mac_game.onPressKeyDict[app.DIK_7] = lambda: _mac_game._GameWindow__PressQuickSlot(6)\n"
"_mac_game.onPressKeyDict[app.DIK_8] = lambda: _mac_game._GameWindow__PressQuickSlot(7)\n"
"for _mac_key in (app.DIK_F1, app.DIK_F2, app.DIK_F3, app.DIK_F4):\n"
" del _mac_game.onPressKeyDict[_mac_key]\n"
"_mac_taskbar = _mac_game.interface.wndTaskBar\n"
"for _mac_slot_number in range(5, 9):\n"
" _mac_taskbar.GetChild('slot_%d' % _mac_slot_number).LoadImage('d:/ymir work/ui/game/taskbar/%d.sub' % _mac_slot_number)\n");
}
#endif
bool parse_live_server_spec(const std::string& spec, std::string& host, int& auth_port, int& game_port) { bool parse_live_server_spec(const std::string& spec, std::string& host, int& auth_port, int& game_port) {
const auto p1 = spec.find(':'); const auto p1 = spec.find(':');
if (p1 == std::string::npos) return false; if (p1 == std::string::npos) return false;
@@ -3206,15 +3257,25 @@ int run_live_client(
bool gpu_skinning, bool gpu_skinning,
bool native_terrain, bool native_terrain,
bool login_screen, bool login_screen,
bool selection_screen,
const std::string& live_server_spec, const std::string& live_server_spec,
const std::string& capture_out) { const std::string& capture_out,
const std::string& screenshot_out) {
if (fake_mobs > 0) { if (fake_mobs > 0) {
const std::string mobs_str = std::to_string(fake_mobs); const std::string mobs_str = std::to_string(fake_mobs);
setenv("MT_FAKE_MOB_COUNT", mobs_str.c_str(), 1); setenv("MT_FAKE_MOB_COUNT", mobs_str.c_str(), 1);
} }
#ifdef __ANDROID__
SetTraceErrorObserver([](const char* line) {
SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "40250 SYSERR: %s", line);
});
#endif
char resolved_client_dir[4096] = {}; char resolved_client_dir[4096] = {};
const std::string abs_client_dir = realpath(client_dir.c_str(), resolved_client_dir) ? std::string(resolved_client_dir) : client_dir; const std::string abs_client_dir = realpath(client_dir.c_str(), resolved_client_dir) ? std::string(resolved_client_dir) : client_dir;
setenv("MT_40250_CLIENT", abs_client_dir.c_str(), 1); setenv("MT_40250_CLIENT", abs_client_dir.c_str(), 1);
#ifdef __ANDROID__
SDL_Log("live stage: pack init %s", abs_client_dir.c_str());
#endif
if (chdir(abs_client_dir.c_str()) != 0 || !mtpack40250::initialize(".")) { if (chdir(abs_client_dir.c_str()) != 0 || !mtpack40250::initialize(".")) {
throw std::runtime_error("cannot initialize 40250 pack at: " + abs_client_dir); throw std::runtime_error("cannot initialize 40250 pack at: " + abs_client_dir);
} }
@@ -3226,6 +3287,9 @@ int run_live_client(
SetNativeTerrainRenderEnabled(native_terrain); SetNativeTerrainRenderEnabled(native_terrain);
SetGpuSkinningEnabled(gpu_skinning); SetGpuSkinningEnabled(gpu_skinning);
NativeAudioEngine audio; NativeAudioEngine audio;
#ifdef __ANDROID__
SDL_Log("live stage: audio initialized");
#endif
std::string server_host = "127.0.0.1"; std::string server_host = "127.0.0.1";
int auth_port = 0; int auth_port = 0;
@@ -3271,11 +3335,17 @@ int run_live_client(
#endif #endif
if (!PythonBoot::Start(stdlib.c_str(), &error)) if (!PythonBoot::Start(stdlib.c_str(), &error))
throw std::runtime_error("PythonBoot::Start: " + error); throw std::runtime_error("PythonBoot::Start: " + error);
#ifdef __ANDROID__
SDL_Log("live stage: PythonBoot started");
#endif
SetPlatformServerTime(123456789); SetPlatformServerTime(123456789);
PythonBoot::SetUISize(static_cast<int>(renderer.logical_width()), static_cast<int>(renderer.logical_height())); PythonBoot::SetUISize(static_cast<int>(renderer.logical_width()), static_cast<int>(renderer.logical_height()));
if (!PythonBoot::RunMainScript("", &error) || !PythonBoot::IsAppLooping()) if (!PythonBoot::RunMainScript("", &error) || !PythonBoot::IsAppLooping())
throw std::runtime_error("PythonBoot::RunMainScript: " + error); throw std::runtime_error("PythonBoot::RunMainScript: " + error);
#ifdef __ANDROID__
SDL_Log("live stage: main script started");
#endif
const std::unordered_map<std::string, std::vector<std::uint8_t>> empty_textures; const std::unordered_map<std::string, std::vector<std::uint8_t>> empty_textures;
auto pump_until = [&](double max_seconds, int sleep_ms, const auto& cond) -> bool { auto pump_until = [&](double max_seconds, int sleep_ms, const auto& cond) -> bool {
@@ -3301,6 +3371,9 @@ int run_live_client(
if (!pump_until(20.0, 5, [] { return py_eval_true("isinstance(_stream.curPhaseWindow, introLogin.LoginWindow)"); })) if (!pump_until(20.0, 5, [] { return py_eval_true("isinstance(_stream.curPhaseWindow, introLogin.LoginWindow)"); }))
throw std::runtime_error("timed out waiting for LoginWindow"); throw std::runtime_error("timed out waiting for LoginWindow");
#ifdef __ANDROID__
SDL_Log("live stage: LoginWindow open");
#endif
if (login_screen) { if (login_screen) {
char conn_cmd[512]; char conn_cmd[512];
@@ -3338,15 +3411,25 @@ int run_live_client(
auth_port, auth_port,
FakeLoginServer::kLogin, FakeLoginServer::kLogin,
FakeLoginServer::kPassword); FakeLoginServer::kPassword);
#ifdef __ANDROID__
SDL_Log("live stage: submitting fake login");
#endif
if (!py_exec(login_cmd)) if (!py_exec(login_cmd))
throw std::runtime_error("failed to submit login credentials"); throw std::runtime_error("failed to submit login credentials");
#ifdef __ANDROID__
SDL_Log("live stage: fake login submitted");
#endif
if (!pump_until(20.0, 5, [&] { if (!pump_until(20.0, 5, [&] {
return (use_external_server || server.Has("game1:login2")) && return (use_external_server || server.Has("game1:login2")) &&
py_eval_true("isinstance(_stream.curPhaseWindow, introSelect.SelectCharacterWindow)"); py_eval_true("isinstance(_stream.curPhaseWindow, introSelect.SelectCharacterWindow)");
})) }))
throw std::runtime_error("timed out waiting for SelectCharacterWindow"); throw std::runtime_error("timed out waiting for SelectCharacterWindow");
#ifdef __ANDROID__
SDL_Log("live stage: SelectCharacterWindow open");
#endif
if (!selection_screen) {
if (!py_exec("_stream.curPhaseWindow.SelectSlot(0)\n" if (!py_exec("_stream.curPhaseWindow.SelectSlot(0)\n"
"_stream.curPhaseWindow.StartGame()")) "_stream.curPhaseWindow.StartGame()"))
throw std::runtime_error("failed to start game from SelectCharacterWindow"); throw std::runtime_error("failed to start game from SelectCharacterWindow");
@@ -3362,6 +3445,15 @@ int run_live_client(
})) }))
throw std::runtime_error("timed out waiting for GameWindow (server error: " + server.Error() + ")"); throw std::runtime_error("timed out waiting for GameWindow (server error: " + server.Error() + ")");
#ifdef __ANDROID__
if (!hide_mobile_taskbar_quickslots())
throw std::runtime_error("failed to hide the desktop quick-slot bar on Android");
#endif
#if defined(__APPLE__) && TARGET_OS_OSX
if (!configure_macos_numeric_quickslots())
throw std::runtime_error("failed to configure numeric quick slots on macOS");
#endif
const int warmup_frames = std::max(10, fake_mobs / 2 + 10); const int warmup_frames = std::max(10, fake_mobs / 2 + 10);
for (int i = 0; i < warmup_frames; ++i) { for (int i = 0; i < warmup_frames; ++i) {
if (!renderer.poll(true) || !PythonBoot::IsAppLooping()) break; if (!renderer.poll(true) || !PythonBoot::IsAppLooping()) break;
@@ -3370,6 +3462,7 @@ int run_live_client(
audio.pump(); audio.pump();
} }
} }
}
// Render one warmup frame to upload initial scene & UI textures/geometries before timed benchmark. // Render one warmup frame to upload initial scene & UI textures/geometries before timed benchmark.
{ {
@@ -3417,6 +3510,10 @@ int run_live_client(
native_draw_capture::write(capture_out, Render3DDraws(), cap_textures, ui_w, ui_h, cap_ui); native_draw_capture::write(capture_out, Render3DDraws(), cap_textures, ui_w, ui_h, cap_ui);
} }
if (!screenshot_out.empty() && frames > 0) {
renderer.request_screenshot(screenshot_out, renderer.frame_number() + static_cast<std::uint64_t>(frames));
}
renderer.reset_timings(); renderer.reset_timings();
const auto start = std::chrono::steady_clock::now(); const auto start = std::chrono::steady_clock::now();
double update_ms = 0.0; double update_ms = 0.0;
@@ -3442,10 +3539,16 @@ int run_live_client(
++completed; ++completed;
} }
renderer.finish_gpu_timings(); renderer.finish_gpu_timings();
#ifdef __ANDROID__
SDL_Log("live stage: render loop complete (%d frames)", completed);
#endif
const auto wall_ms = std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - start).count(); const auto wall_ms = std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - start).count();
print_summary(renderer, completed, wall_ms, update_ms, std::move(frame_ms)); print_summary(renderer, completed, wall_ms, update_ms, std::move(frame_ms));
PythonBoot::Stop(); PythonBoot::Stop();
#ifdef __ANDROID__
SDL_Log("live stage: PythonBoot stopped");
#endif
if (!use_external_server) if (!use_external_server)
server.Stop(); server.Stop();
return (frames == 0 || completed == frames) ? 0 : 2; return (frames == 0 || completed == frames) ? 0 : 2;
@@ -3461,6 +3564,7 @@ int main(int argc, char** argv) {
bool synthetic_specified = false; bool synthetic_specified = false;
bool interactive = false; bool interactive = false;
bool login_screen = false; bool login_screen = false;
bool selection_screen = false;
int init_width = 1280; int init_width = 1280;
int init_height = 800; int init_height = 800;
std::size_t draw_count = 64; std::size_t draw_count = 64;
@@ -3489,6 +3593,9 @@ int main(int argc, char** argv) {
else if (arg == "--login-screen") { else if (arg == "--login-screen") {
login_screen = true; login_screen = true;
interactive = true; interactive = true;
} else if (arg == "--selection-screen") {
selection_screen = true;
interactive = true;
} else if (arg == "--live-server" && i+1 < argc) { } else if (arg == "--live-server" && i+1 < argc) {
live_server_spec = argv[++i]; live_server_spec = argv[++i];
interactive = true; interactive = true;
@@ -3525,7 +3632,7 @@ int main(int argc, char** argv) {
else if (arg == "--no-terrain") native_terrain = false; else if (arg == "--no-terrain") native_terrain = false;
else if (arg == "--mobile") mobile_mode = true; else if (arg == "--mobile") mobile_mode = true;
else throw std::runtime_error( else throw std::runtime_error(
"usage: mt_native_render [--frames N] [--interactive] [--login-screen] " "usage: mt_native_render [--frames N] [--interactive] [--login-screen|--selection-screen] "
"[--live-server HOST:AUTH_PORT:GAME_PORT] [--width W] [--height H] " "[--live-server HOST:AUTH_PORT:GAME_PORT] [--width W] [--height H] "
"[--draws N] [--triangles-per-draw N] [--capture FILE] [--animate-first-draw] " "[--draws N] [--triangles-per-draw N] [--capture FILE] [--animate-first-draw] "
"[--animate-bones] [--no-vsync] [--live-client DIR] [--fake-mobs N] " "[--animate-bones] [--no-vsync] [--live-client DIR] [--fake-mobs N] "
@@ -3558,6 +3665,12 @@ int main(int argc, char** argv) {
} }
} }
if (interactive && !frames_specified) frames = 0; if (interactive && !frames_specified) frames = 0;
#ifdef __ANDROID__
SDL_Log("native startup: argc=%d live_client=%s selection=%d frames=%d", argc,
live_client_dir.c_str(), selection_screen, frames);
#endif
if (login_screen && selection_screen)
throw std::runtime_error("--login-screen and --selection-screen are mutually exclusive");
if (frames < 0 || (!interactive && frames == 0) || draw_count == 0 || draw_count > 4096 || if (frames < 0 || (!interactive && frames == 0) || draw_count == 0 || draw_count > 4096 ||
triangles_per_draw == 0 || triangles_per_draw > 10000) triangles_per_draw == 0 || triangles_per_draw > 10000)
throw std::runtime_error("invalid frame/draw/triangle count"); throw std::runtime_error("invalid frame/draw/triangle count");
@@ -3569,7 +3682,7 @@ int main(int argc, char** argv) {
} }
if (!live_client_dir.empty()) { if (!live_client_dir.empty()) {
#ifdef MT_NATIVE_HAS_LIVE_CLIENT #ifdef MT_NATIVE_HAS_LIVE_CLIENT
return run_live_client( const int result = run_live_client(
renderer, renderer,
live_client_dir, live_client_dir,
frames, frames,
@@ -3577,8 +3690,14 @@ int main(int argc, char** argv) {
gpu_skinning, gpu_skinning,
native_terrain, native_terrain,
login_screen, login_screen,
selection_screen,
live_server_spec, live_server_spec,
capture_out); capture_out,
screenshot_out);
#ifdef __ANDROID__
SDL_Log("native live client exit: %d", result);
#endif
return result;
#else #else
throw std::runtime_error("mt_native_render was built without port_platform (--live-client unavailable)"); throw std::runtime_error("mt_native_render was built without port_platform (--live-client unavailable)");
#endif #endif
@@ -3620,6 +3739,9 @@ int main(int argc, char** argv) {
return (frames == 0 || completed == frames) ? 0 : 2; return (frames == 0 || completed == frames) ? 0 : 2;
} catch (const std::exception& error) { } catch (const std::exception& error) {
std::cerr << "native renderer: " << error.what() << '\n'; std::cerr << "native renderer: " << error.what() << '\n';
#ifdef __ANDROID__
SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "native renderer: %s", error.what());
#endif
return 1; return 1;
} }
} }
+127 -206
View File
@@ -17,9 +17,7 @@
class TouchController { class TouchController {
public: public:
struct ButtonDef { struct ButtonDef {
int id; // 1 = ATK, 2 = S1, 3 = S2, 4 = S3, 5 = POT, 6 = PICK int id; // 1 = attack, 2..7 = quick slots 1..6
// 99 = MENU (toggle drawer)
// 10 = BAG (DIK_I), 11 = CHAR (DIK_C), 12 = SKILL (DIK_V), 13 = QUEST (DIK_N), 14 = COMM (DIK_M), 15 = SET (DIK_ESCAPE)
int dik; int dik;
float x, y, radius; float x, y, radius;
const char* label; const char* label;
@@ -53,6 +51,14 @@ public:
// Called once per frame to maintain continuous analog movement // Called once per frame to maintain continuous analog movement
void update() { void update() {
if (!enabled_) return; if (!enabled_) return;
const bool controls_visible = gameplay_controls_visible();
if (!controls_visible) {
if (controls_were_visible_)
release_gameplay_controls();
controls_were_visible_ = false;
return;
}
controls_were_visible_ = true;
if (joystick_active_) { if (joystick_active_) {
#ifdef MT_NATIVE_HAS_LIVE_CLIENT #ifdef MT_NATIVE_HAS_LIVE_CLIENT
PythonBoot::SetMoveDirection(move_angle_, true); PythonBoot::SetMoveDirection(move_angle_, true);
@@ -65,23 +71,25 @@ public:
if (!enabled_) return false; if (!enabled_) return false;
const float px = norm_x * float(screen_w_); const float px = norm_x * float(screen_w_);
const float py = norm_y * float(screen_h_); const float py = norm_y * float(screen_h_);
const bool controls_visible = gameplay_controls_visible();
// 1. Check HUD buttons (Action cluster & Drawer menu) // The mobile overlay owns only the movement and combat control regions.
int btn_idx = find_button(px, py); if (controls_visible) {
const int btn_idx = find_button(px, py);
if (btn_idx >= 0) { if (btn_idx >= 0) {
auto& btn = buttons_[btn_idx]; auto& btn = buttons_[btn_idx];
if (btn.id == 99) { // MENU toggle button #ifdef MT_NATIVE_HAS_LIVE_CLIENT
drawer_open_ = !drawer_open_; if (btn.id >= 2 && PythonBoot::TryAssignAttachedObjectToLocalQuickSlot(btn.id - 2))
layout_buttons();
return true; return true;
} #endif
btn.pressed = true; btn.pressed = true;
btn.finger_id = finger_id; btn.finger_id = finger_id;
trigger_button(btn.dik, true); trigger_button(btn.dik, true);
return true; return true;
} }
}
// 2. Intelligent UI Hit-Testing: check if touch lands inside an open in-game UI window // Everything else uses the unchanged 40250 UI and its normal mouse path.
#ifdef MT_NATIVE_HAS_LIVE_CLIENT #ifdef MT_NATIVE_HAS_LIVE_CLIENT
if (PythonBoot::IsPointInsideActiveUI(int(px), int(py))) { if (PythonBoot::IsPointInsideActiveUI(int(px), int(py))) {
ui_touch_finger_id_ = finger_id; ui_touch_finger_id_ = finger_id;
@@ -91,8 +99,8 @@ public:
} }
#endif #endif
// 3. Left side: virtual joystick (lower-left quadrant) // Start movement only in the gameplay phase.
if (norm_x < 0.40f && norm_y > 0.35f && !joystick_active_) { if (controls_visible && norm_x < 0.40f && norm_y > 0.35f && !joystick_active_) {
joystick_active_ = true; joystick_active_ = true;
joystick_finger_id_ = finger_id; joystick_finger_id_ = finger_id;
joystick_base_x_ = px; joystick_base_x_ = px;
@@ -103,7 +111,10 @@ public:
return true; return true;
} }
// 4. 3D Game World: camera rotation, pinch zoom, or tap to target if (!controls_visible)
return false;
// The remaining gameplay area controls camera and world selection.
if (camera_finger_id_ < 0) { if (camera_finger_id_ < 0) {
camera_finger_id_ = finger_id; camera_finger_id_ = finger_id;
camera_last_x_ = px; camera_last_x_ = px;
@@ -265,80 +276,12 @@ public:
return false; return false;
} }
// Render virtual joystick, buttons, drawer menu, and player status bar // Overlay only the controls that have no practical desktop-UI touch equivalent.
void append_ui_commands(std::vector<UIRenderCommand>& commands) const { void append_ui_commands(std::vector<UIRenderCommand>& commands) const {
if (!enabled_) return; if (!enabled_ || !gameplay_controls_visible()) return;
const float W = float(screen_w_); // Right-side attack and quick-slot buttons.
#ifdef MT_NATIVE_HAS_LIVE_CLIENT
// --- 1. Top-Left Player Status Bar (Mobile HUD) ---
const auto status = PythonBoot::GetPlayerStatusInfo();
if (status.max_hp > 0) {
const float bar_x = 20.0f;
const float bar_y = 16.0f;
const float bar_w = 205.0f;
const float bar_h = 44.0f;
// Background plate
draw_rect_bar(commands, bar_x, bar_y, bar_x + bar_w, bar_y + bar_h, 0x90101824);
draw_rect_lines(commands, bar_x, bar_y, bar_x + bar_w, bar_y + bar_h, 0x60405870);
// Avatar circle badge
const float av_cx = bar_x + 22.0f;
const float av_cy = bar_y + 22.0f;
const float av_r = 15.0f;
draw_filled_disc(commands, av_cx, av_cy, av_r, 0xB0203040);
draw_circle(commands, av_cx, av_cy, av_r, 0xE0FFD700, 16);
// "Lv" tick inside avatar
draw_line(commands, av_cx - 4.0f, av_cy - 4.0f, av_cx - 4.0f, av_cy + 3.0f, 0xFFFFFFFF);
draw_line(commands, av_cx - 4.0f, av_cy + 3.0f, av_cx - 1.0f, av_cy + 3.0f, 0xFFFFFFFF);
draw_line(commands, av_cx + 1.0f, av_cy - 4.0f, av_cx + 3.0f, av_cy + 3.0f, 0xFFFFFFFF);
draw_line(commands, av_cx + 5.0f, av_cy - 4.0f, av_cx + 3.0f, av_cy + 3.0f, 0xFFFFFFFF);
// Gauges
const float gx1 = bar_x + 44.0f;
const float gx2 = bar_x + bar_w - 10.0f;
const float gw = gx2 - gx1;
// HP Gauge
const float hp_y1 = bar_y + 10.0f;
const float hp_y2 = hp_y1 + 10.0f;
const float hp_ratio = std::clamp(float(status.hp) / float(status.max_hp), 0.0f, 1.0f);
draw_rect_bar(commands, gx1, hp_y1, gx2, hp_y2, 0x70301010);
draw_rect_bar(commands, gx1, hp_y1, gx1 + gw * hp_ratio, hp_y2, 0xE5D82424);
draw_line(commands, gx1, hp_y1, gx1 + gw * hp_ratio, hp_y1, 0x60FFFFFF); // Gloss line
draw_rect_lines(commands, gx1, hp_y1, gx2, hp_y2, 0x80502020);
// MP Gauge
const float mp_y1 = bar_y + 24.0f;
const float mp_y2 = mp_y1 + 8.0f;
const float mp_ratio = status.max_sp > 0 ? std::clamp(float(status.sp) / float(status.max_sp), 0.0f, 1.0f) : 0.0f;
draw_rect_bar(commands, gx1, mp_y1, gx2, mp_y2, 0x70102038);
draw_rect_bar(commands, gx1, mp_y1, gx1 + gw * mp_ratio, mp_y2, 0xE52068E0);
draw_line(commands, gx1, mp_y1, gx1 + gw * mp_ratio, mp_y1, 0x60FFFFFF); // Gloss line
draw_rect_lines(commands, gx1, mp_y1, gx2, mp_y2, 0x80204060);
// EXP bar (bottom trim line)
if (status.max_exp > 0) {
const float exp_ratio = std::clamp(float(status.exp) / float(status.max_exp), 0.0f, 1.0f);
draw_rect_bar(commands, bar_x, bar_y + bar_h - 2.0f, bar_x + bar_w * exp_ratio, bar_y + bar_h, 0xD0E0B020);
}
}
#endif
// --- 2. Top-Right Drawer Menu Bar ---
if (drawer_open_) {
// Background capsule tray behind drawer buttons
draw_rect_bar(commands, W - 468.0f, 16.0f, W - 198.0f, 54.0f, 0x90101824);
draw_rect_lines(commands, W - 468.0f, 16.0f, W - 198.0f, 54.0f, 0x60506880);
}
// --- 3. Buttons Rendering (Combat wheel & Drawer items) ---
for (const auto& btn : buttons_) { for (const auto& btn : buttons_) {
if (btn.id >= 10 && btn.id <= 15 && !drawer_open_)
continue;
const uint32_t col = btn.pressed ? btn.color_pressed : btn.color_idle; const uint32_t col = btn.pressed ? btn.color_pressed : btn.color_idle;
draw_filled_disc(commands, btn.x, btn.y, btn.radius, col); draw_filled_disc(commands, btn.x, btn.y, btn.radius, col);
draw_circle(commands, btn.x, btn.y, btn.radius * 0.85f, btn.pressed ? 0xFFFFFFFF : 0x70FFFFFF, 16); draw_circle(commands, btn.x, btn.y, btn.radius * 0.85f, btn.pressed ? 0xFFFFFFFF : 0x70FFFFFF, 16);
@@ -346,6 +289,24 @@ public:
const uint32_t icon_col = btn.pressed ? 0xFFFFFFFF : 0xDDFFFFFF; const uint32_t icon_col = btn.pressed ? 0xFFFFFFFF : 0xDDFFFFFF;
const float r = btn.radius; const float r = btn.radius;
#ifdef MT_NATIVE_HAS_LIVE_CLIENT
if (btn.id >= 2) {
const std::string skill_icon = PythonBoot::LocalQuickSlotSkillIcon(btn.id - 2);
if (!skill_icon.empty()) {
UIRenderCommand icon{};
icon.kind = UIRenderCommand::Image;
icon.x1 = btn.x - r * 0.68f;
icon.y1 = btn.y - r * 0.68f;
icon.x2 = btn.x + r * 0.68f;
icon.y2 = btn.y + r * 0.68f;
icon.argb = icon_col;
icon.text = skill_icon;
commands.push_back(std::move(icon));
continue;
}
}
#endif
switch (btn.id) { switch (btn.id) {
case 1: { // ATK: crossed swords case 1: { // ATK: crossed swords
const float s = r * 0.35f; const float s = r * 0.35f;
@@ -376,69 +337,30 @@ public:
draw_line(commands, btn.x + 6.0f, btn.y - h, btn.x + 6.0f, btn.y + h, icon_col); draw_line(commands, btn.x + 6.0f, btn.y - h, btn.x + 6.0f, btn.y + h, icon_col);
break; break;
} }
case 5: { // POT: + case 5: { // S4: IV
const float p = r * 0.4f; const float h = r * 0.35f;
draw_line(commands, btn.x - p, btn.y, btn.x + p, btn.y, icon_col); draw_line(commands, btn.x - 7.0f, btn.y - h, btn.x - 7.0f, btn.y + h, icon_col);
draw_line(commands, btn.x, btn.y - p, btn.x, btn.y + p, icon_col); draw_line(commands, btn.x, btn.y - h, btn.x + 7.0f, btn.y + h, icon_col);
draw_line(commands, btn.x + 14.0f, btn.y - h, btn.x + 7.0f, btn.y + h, icon_col);
break; break;
} }
case 6: { // PICK: Downward arrow case 6: { // S5: V
const float a = r * 0.35f; const float h = r * 0.35f;
draw_line(commands, btn.x - a, btn.y - a * 0.3f, btn.x, btn.y + a * 0.6f, icon_col); draw_line(commands, btn.x - 8.0f, btn.y - h, btn.x, btn.y + h, icon_col);
draw_line(commands, btn.x + a, btn.y - a * 0.3f, btn.x, btn.y + a * 0.6f, icon_col); draw_line(commands, btn.x + 8.0f, btn.y - h, btn.x, btn.y + h, icon_col);
draw_line(commands, btn.x, btn.y - a * 0.7f, btn.x, btn.y + a * 0.6f, icon_col);
break; break;
} }
case 99: { // MENU: ☰ hamburger icon case 7: { // S6: VI
draw_line(commands, btn.x - 7.0f, btn.y - 5.0f, btn.x + 7.0f, btn.y - 5.0f, icon_col); const float h = r * 0.35f;
draw_line(commands, btn.x - 7.0f, btn.y, btn.x + 7.0f, btn.y, icon_col); draw_line(commands, btn.x - 11.0f, btn.y - h, btn.x - 3.0f, btn.y + h, icon_col);
draw_line(commands, btn.x - 7.0f, btn.y + 5.0f, btn.x + 7.0f, btn.y + 5.0f, icon_col); draw_line(commands, btn.x + 5.0f, btn.y - h, btn.x - 3.0f, btn.y + h, icon_col);
break; draw_line(commands, btn.x + 11.0f, btn.y - h, btn.x + 11.0f, btn.y + h, icon_col);
}
case 10: { // BAG: Backpack
draw_rect_lines(commands, btn.x - 6.0f, btn.y - 4.0f, btn.x + 6.0f, btn.y + 6.0f, icon_col);
draw_line(commands, btn.x - 3.0f, btn.y - 4.0f, btn.x, btn.y - 7.0f, icon_col);
draw_line(commands, btn.x, btn.y - 7.0f, btn.x + 3.0f, btn.y - 4.0f, icon_col);
draw_line(commands, btn.x - 6.0f, btn.y, btn.x + 6.0f, btn.y, icon_col);
break;
}
case 11: { // CHAR: Head + Shoulders
draw_circle(commands, btn.x, btn.y - 3.0f, 4.0f, icon_col, 12);
draw_line(commands, btn.x - 6.0f, btn.y + 6.0f, btn.x - 3.0f, btn.y + 2.0f, icon_col);
draw_line(commands, btn.x - 3.0f, btn.y + 2.0f, btn.x + 3.0f, btn.y + 2.0f, icon_col);
draw_line(commands, btn.x + 3.0f, btn.y + 2.0f, btn.x + 6.0f, btn.y + 6.0f, icon_col);
break;
}
case 12: { // SKILL: Lightning
draw_line(commands, btn.x + 2.0f, btn.y - 7.0f, btn.x - 3.0f, btn.y - 1.0f, icon_col);
draw_line(commands, btn.x - 3.0f, btn.y - 1.0f, btn.x + 1.0f, btn.y - 1.0f, icon_col);
draw_line(commands, btn.x + 1.0f, btn.y - 1.0f, btn.x - 2.0f, btn.y + 7.0f, icon_col);
break;
}
case 13: { // QUEST: Scroll
draw_rect_lines(commands, btn.x - 5.0f, btn.y - 6.0f, btn.x + 5.0f, btn.y + 6.0f, icon_col);
draw_line(commands, btn.x - 3.0f, btn.y - 2.0f, btn.x + 3.0f, btn.y - 2.0f, icon_col);
draw_line(commands, btn.x - 3.0f, btn.y + 2.0f, btn.x + 1.0f, btn.y + 2.0f, icon_col);
break;
}
case 14: { // COMM: Chat bubble
draw_rect_lines(commands, btn.x - 6.0f, btn.y - 5.0f, btn.x + 6.0f, btn.y + 3.0f, icon_col);
draw_line(commands, btn.x - 3.0f, btn.y + 3.0f, btn.x - 5.0f, btn.y + 6.0f, icon_col);
draw_line(commands, btn.x - 5.0f, btn.y + 6.0f, btn.x, btn.y + 3.0f, icon_col);
break;
}
case 15: { // SET: Gear / Close
draw_circle(commands, btn.x, btn.y, 4.0f, icon_col, 10);
draw_line(commands, btn.x - 7.0f, btn.y, btn.x + 7.0f, btn.y, icon_col);
draw_line(commands, btn.x, btn.y - 7.0f, btn.x, btn.y + 7.0f, icon_col);
draw_line(commands, btn.x - 5.0f, btn.y - 5.0f, btn.x + 5.0f, btn.y + 5.0f, icon_col);
draw_line(commands, btn.x - 5.0f, btn.y + 5.0f, btn.x + 5.0f, btn.y - 5.0f, icon_col);
break; break;
} }
} }
} }
// --- 4. Virtual Joystick --- // Left-side movement joystick.
draw_circle(commands, joystick_base_x_, joystick_base_y_, joystick_radius_, 0x8080C0FF, 24); draw_circle(commands, joystick_base_x_, joystick_base_y_, joystick_radius_, 0x8080C0FF, 24);
draw_circle(commands, joystick_base_x_, joystick_base_y_, joystick_radius_ * 0.45f, 0x4080C0FF, 16); draw_circle(commands, joystick_base_x_, joystick_base_y_, joystick_radius_ * 0.45f, 0x4080C0FF, 16);
draw_line(commands, joystick_base_x_ - joystick_radius_, joystick_base_y_, draw_line(commands, joystick_base_x_ - joystick_radius_, joystick_base_y_,
@@ -494,26 +416,52 @@ public:
} }
private: private:
bool gameplay_controls_visible() const {
#ifdef MT_NATIVE_HAS_LIVE_CLIENT
return !PythonBoot::CurrentMapName().empty();
#else
return false;
#endif
}
void release_gameplay_controls() {
if (joystick_active_) {
#ifdef MT_NATIVE_HAS_LIVE_CLIENT
PythonBoot::SetMoveDirection(0.0f, false);
#endif
}
joystick_active_ = false;
joystick_finger_id_ = -1;
joystick_knob_x_ = joystick_base_x_;
joystick_knob_y_ = joystick_base_y_;
for (auto& btn : buttons_) {
if (btn.pressed)
trigger_button(btn.dik, false);
btn.pressed = false;
btn.finger_id = -1;
}
if (camera_dragged_) {
#ifdef MT_NATIVE_HAS_LIVE_CLIENT
PythonBoot::CameraEndDrag();
#endif
}
camera_finger_id_ = -1;
camera_dragged_ = false;
pinch_finger1_ = -1;
pinch_finger2_ = -1;
pinch_last_dist_ = 0.0f;
}
void init_buttons() { void init_buttons() {
buttons_.clear(); buttons_.clear();
// Combat Action Wheel (Lower-Right) // Lower-right combat controls. All menus and status UI remain the original 40250 UI.
buttons_.push_back({1, 0x39, 0, 0, 42.0f, "ATK", 0x80D48820, 0xD0FFB040}); buttons_.push_back({1, 0x39, 0, 0, 42.0f, "ATK", 0x80D48820, 0xD0FFB040});
buttons_.push_back({2, 0x02, 0, 0, 26.0f, "S1", 0x803060C0, 0xD05080FF}); buttons_.push_back({2, 0x02, 0, 0, 26.0f, "S1", 0x803060C0, 0xD05080FF});
buttons_.push_back({3, 0x03, 0, 0, 26.0f, "S2", 0x80903090, 0xD0D050D0}); buttons_.push_back({3, 0x03, 0, 0, 26.0f, "S2", 0x80903090, 0xD0D050D0});
buttons_.push_back({4, 0x04, 0, 0, 26.0f, "S3", 0x80309060, 0xD050D080}); buttons_.push_back({4, 0x04, 0, 0, 26.0f, "S3", 0x80309060, 0xD050D080});
buttons_.push_back({5, 0x05, 0, 0, 22.0f, "POT", 0x90A03030, 0xD0FF5050}); buttons_.push_back({5, 0x05, 0, 0, 26.0f, "S4", 0x80906030, 0xD0D08050});
buttons_.push_back({6, 0x2c, 0, 0, 22.0f, "PICK", 0x80208080, 0xD040B0B0}); buttons_.push_back({6, 0x06, 0, 0, 26.0f, "S5", 0x80603090, 0xD08050D0});
buttons_.push_back({7, 0x07, 0, 0, 26.0f, "S6", 0x80308090, 0xD050C0D0});
// Top-Right Drawer Menu Toggle
buttons_.push_back({99, 0, 0, 0, 20.0f, "MENU", 0x90283848, 0xD0FFB040});
// Drawer Menu Buttons
buttons_.push_back({10, 0x17, 0, 0, 18.0f, "BAG", 0x90D09020, 0xD0FFB040}); // DIK_I
buttons_.push_back({11, 0x2e, 0, 0, 18.0f, "CHAR", 0x903060B0, 0xD05080FF}); // DIK_C
buttons_.push_back({12, 0x2f, 0, 0, 18.0f, "SKILL", 0x90803090, 0xD0D050D0}); // DIK_V
buttons_.push_back({13, 0x31, 0, 0, 18.0f, "QUEST", 0x90308050, 0xD050D070}); // DIK_N
buttons_.push_back({14, 0x32, 0, 0, 18.0f, "COMM", 0x90905020, 0xD0E07030}); // DIK_M
buttons_.push_back({15, 0x01, 0, 0, 18.0f, "SET", 0x90506070, 0xD08090A0}); // DIK_ESCAPE
} }
void layout_buttons() { void layout_buttons() {
@@ -522,70 +470,40 @@ private:
for (auto& btn : buttons_) { for (auto& btn : buttons_) {
switch (btn.id) { switch (btn.id) {
case 1: case 1:
btn.x = W - 85.0f; btn.x = W - 75.0f;
btn.y = H - 85.0f; btn.y = H - 85.0f;
btn.radius = std::min(46.0f, H * 0.12f); btn.radius = std::min(46.0f, H * 0.12f);
break; break;
case 2: case 2:
btn.x = W - 360.0f;
btn.y = H - 75.0f;
btn.radius = std::min(28.0f, H * 0.08f);
break;
case 3:
btn.x = W - 295.0f;
btn.y = H - 75.0f;
btn.radius = std::min(28.0f, H * 0.08f);
break;
case 4:
btn.x = W - 230.0f;
btn.y = H - 75.0f;
btn.radius = std::min(28.0f, H * 0.08f);
break;
case 5:
btn.x = W - 165.0f; btn.x = W - 165.0f;
btn.y = H - 85.0f; btn.y = H - 85.0f;
btn.radius = std::min(28.0f, H * 0.08f); btn.radius = std::min(28.0f, H * 0.08f);
break; break;
case 3: case 6:
btn.x = W - 145.0f; btn.x = W - 125.0f;
btn.y = H - 155.0f; btn.y = H - 155.0f;
btn.radius = std::min(28.0f, H * 0.08f); btn.radius = std::min(28.0f, H * 0.08f);
break; break;
case 4: case 7:
btn.x = W - 85.0f; btn.x = W - 75.0f;
btn.y = H - 175.0f; btn.y = H - 225.0f;
btn.radius = std::min(28.0f, H * 0.08f); btn.radius = std::min(28.0f, H * 0.08f);
break; break;
case 5:
btn.x = W - 225.0f;
btn.y = H - 75.0f;
btn.radius = std::min(24.0f, H * 0.065f);
break;
case 6:
btn.x = W - 85.0f;
btn.y = H - 235.0f;
btn.radius = std::min(24.0f, H * 0.065f);
break;
case 99: // MENU toggle button (to the left of MiniMap)
btn.x = W - 170.0f;
btn.y = 35.0f;
btn.radius = 20.0f;
break;
case 10: // BAG
btn.x = W - 220.0f;
btn.y = 35.0f;
btn.radius = 18.0f;
break;
case 11: // CHAR
btn.x = W - 265.0f;
btn.y = 35.0f;
btn.radius = 18.0f;
break;
case 12: // SKILL
btn.x = W - 310.0f;
btn.y = 35.0f;
btn.radius = 18.0f;
break;
case 13: // QUEST
btn.x = W - 355.0f;
btn.y = 35.0f;
btn.radius = 18.0f;
break;
case 14: // COMM
btn.x = W - 400.0f;
btn.y = 35.0f;
btn.radius = 18.0f;
break;
case 15: // SET
btn.x = W - 445.0f;
btn.y = 35.0f;
btn.radius = 18.0f;
break;
} }
} }
} }
@@ -593,8 +511,6 @@ private:
int find_button(float x, float y) { int find_button(float x, float y) {
for (size_t i = 0; i < buttons_.size(); ++i) { for (size_t i = 0; i < buttons_.size(); ++i) {
const auto& btn = buttons_[i]; const auto& btn = buttons_[i];
if (btn.id >= 10 && btn.id <= 15 && !drawer_open_)
continue;
const float dist = std::hypot(x - btn.x, y - btn.y); const float dist = std::hypot(x - btn.x, y - btn.y);
if (dist <= btn.radius * 1.25f) { if (dist <= btn.radius * 1.25f) {
return int(i); return int(i);
@@ -608,6 +524,11 @@ private:
if (dik == 0x39) { if (dik == 0x39) {
PythonBoot::SetAttackKey(pressed); PythonBoot::SetAttackKey(pressed);
} }
if (dik >= 0x02 && dik <= 0x07) {
if (pressed)
PythonBoot::UseLocalQuickSlot(dik - 0x02);
return;
}
if (dik != 0) { if (dik != 0) {
PythonBoot::UIKey(dik, pressed); PythonBoot::UIKey(dik, pressed);
} }
@@ -703,7 +624,7 @@ private:
int screen_w_ = 1280; int screen_w_ = 1280;
int screen_h_ = 720; int screen_h_ = 720;
bool drawer_open_ = false; bool controls_were_visible_ = false;
int64_t ui_touch_finger_id_ = -1; int64_t ui_touch_finger_id_ = -1;
bool joystick_active_ = false; bool joystick_active_ = false;
+2 -2
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# M2 门禁·主:对测试资产集跑 oracle dump + oracle_diff,汇总 test/m2-numeric.json。 # M2 门禁·主:对测试资产集跑 oracle dump + oracle_diff,汇总 test/m2-numeric.json。
# 前置:oracle/build-wine.sh 已产出 oracle.exe;WINEPREFIX 已 init(见 RUNBOOK.md); # 前置:oracle/build-wine.sh 已产出 oracle.exe;WINEPREFIX 已 init(见 RUNBOOK.md);
# oracle_diff 已构建(cmake -B build-tools -DMTGODOT_BUILD_EXTENSION=OFF -DMTGODOT_BUILD_TOOLS=ON)。 # oracle_diff 已构建(cmake -B build-tools -DMT_BUILD_TOOLS=ON)。
set -uo pipefail set -uo pipefail
cd "$(dirname "$0")/.." cd "$(dirname "$0")/.."
REPO="$(pwd)" REPO="$(pwd)"
@@ -11,7 +11,7 @@ DIFF=""
for c in "$REPO/build-tools/tools/oracle_diff" "$REPO/build/tools/oracle_diff"; do for c in "$REPO/build-tools/tools/oracle_diff" "$REPO/build/tools/oracle_diff"; do
[ -x "$c" ] && DIFF="$c" && break [ -x "$c" ] && DIFF="$c" && break
done done
[ -n "$DIFF" ] || { echo "oracle_diff not built — run: cmake -B build-tools -DMTGODOT_BUILD_EXTENSION=OFF -DMTGODOT_BUILD_TOOLS=ON && cmake --build build-tools"; exit 2; } [ -n "$DIFF" ] || { echo "oracle_diff not built — run: cmake -B build-tools -DMT_BUILD_TOOLS=ON && cmake --build build-tools"; exit 2; }
mkdir -p "$DUMPS" mkdir -p "$DUMPS"
WINE="${WINE:-/tmp/wine/Wine Stable.app/Contents/Resources/wine/bin/wine}" WINE="${WINE:-/tmp/wine/Wine Stable.app/Contents/Resources/wine/bin/wine}"
-41
View File
@@ -1,41 +0,0 @@
#!/usr/bin/env bash
# Pack the Metin2 asset tree into build/export/assets.zip for the mobile builds.
#
# The APK/IPA is code-only (assets are gitignored and can't go in res://). The
# zip is mounted at runtime by AssetPack.ensure() -> res://assets/... and read
# through godot::FileAccess (all C++ asset IO now goes through it).
#
# ./pack-assets.sh # bake index + zip -0 (store, no compression)
#
# Deploy:
# Android: adb push build/export/assets.zip /sdcard/Android/data/org.internal.mtgodotpoc/files/
# iOS: ios-deploy --bundle_id org.internal.mtgodotpoc --upload build/export/assets.zip --to Documents/
# desktop: not needed (loose assets/ is used directly)
set -euo pipefail
cd "$(dirname "$0")"
GODOT="${GODOT:-godot}"
OUT="build/export/assets.zip"
if [ ! -d assets ]; then
echo "no ./assets — nothing to pack" >&2
exit 1
fi
# 1. refresh the AssetResolver index (baked; device can't std::filesystem-scan a pack)
"$GODOT" --headless --path project --script bake_asset_index.gd || {
echo "asset_index bake failed" >&2; exit 1; }
# 2. zip -0 (store): DDS/gr2 barely compress and we want fast random access from
# FileAccess; -X drops extra file attrs. Paths stay assets/... bgm/... so they
# mount as res://assets/... res://bgm/...
mkdir -p build/export
rm -f "$OUT"
zip -0 -r -X "$OUT" assets bgm \
-x '*/.git/*' -x '*.DS_Store' -x 'assets/.gdignore'
SZ=$(du -h "$OUT" | cut -f1)
N=$(unzip -l "$OUT" | tail -1 | awk '{print $2}')
echo
echo ">> $OUT ($SZ, $N entries)"
echo ">> Android: adb push $OUT /sdcard/Android/data/org.internal.mtgodotpoc/files/"
-60
View File
@@ -1,60 +0,0 @@
# AssetPack —— 把 Metin2 资源(assets/ + bgm/ + asset_index.txt 打成的 assets.zip)
# 挂载到 res://,供 AssetRoot / C++ 的 FileAccess 读取。
#
# 桌面开发:仓库旁的散 assets/ 直接可用 -> ensure() 什么都不做。
# 移动端:APK/IPA 里没有资源;assets.zip 由外部提供:
# Android —— adb push build/export/assets.zip /sdcard/Android/data/<pkg>/files/
# iOS —— ios-deploy --upload assets.zip --to Documents/
# 找到就 ProjectSettings.load_resource_pack() 挂到 res://。
#
# 候选路径:MT_ASSETS_ZIP 环境变量 -> user://assets.zip ->
# <可执行文件目录>/assets.zip -> res://../assets.zip(开发)
#
# FUTURE(下载版):_find_local_zip() 失败时,改为 HTTPRequest 从 ASSET_PACK_URL
# 下载到 user://assets.zip(断点续传 + sha256 校验 + 进度 UI),再走同一个 mount。
class_name AssetPack
extends RefCounted
const ZIP_NAME := "assets.zip"
# const ASSET_PACK_URL := "" # FUTURE: 下载版填这里
static var mounted := false
static var last_source := ""
# 返回 true = 资源可用(散文件 或 已挂载 zip)。同步;本地文件场景不阻塞。
# force_zip=true:即使散 assets/ 可用也去挂 zip(测试 / 验证移动端路径用)。
static func ensure(force_zip := false) -> bool:
if mounted:
return true
if not force_zip and AssetRoot.available(): # 散 assets/(桌面开发)
return true
var zip := _find_local_zip()
if zip == "":
# FUTURE: 这里改成下载 ASSET_PACK_URL -> user://assets.zip -> 继续
push_warning("[AssetPack] 找不到 %s(Android: adb push 到 files/;iOS: 上传到 Documents/)" % ZIP_NAME)
return false
if ProjectSettings.load_resource_pack(zip, false):
mounted = true
last_source = zip
print("[AssetPack] mounted ", zip)
return true
push_warning("[AssetPack] load_resource_pack 失败: " + zip)
return false
static func _find_local_zip() -> String:
var cands: Array[String] = []
var env := OS.get_environment("MT_ASSETS_ZIP")
if env != "":
cands.append(env)
cands.append("user://" + ZIP_NAME)
var data_dir := OS.get_user_data_dir()
if data_dir != "":
cands.append(data_dir.path_join(ZIP_NAME))
var exe := OS.get_executable_path()
if exe != "":
cands.append(exe.get_base_dir().path_join(ZIP_NAME))
cands.append("res://../" + ZIP_NAME) # 开发:仓库根旁
for c in cands:
if FileAccess.file_exists(c):
return c
return ""
-1
View File
@@ -1 +0,0 @@
uid://f260rrsoxa0p
-80
View File
@@ -1,80 +0,0 @@
# AssetRoot —— Metin2 资源根目录的唯一解析点。
#
# 资源(`.gr2` / `.dds` / `locale` / `OutdoorA1` / `uiscript` …)不进版本库。
# 解析顺序:
# 1. 环境变量 MT_ASSETS(绝对路径)
# 2. res://../assets —— 开发树(仓库根的 assets/)
# 3. <可执行文件>/../Resources/assets —— 打包进 .app 内
# 4. <.app 同级>/assets —— .app 旁边放一份
# 都没有就返回 res://../assets(调用方自行 warn)。
#
# var root := AssetRoot.path()
# var il := AssetRoot.sub("locale/locale/common/item_list.txt")
# if AssetRoot.available(): ...
class_name AssetRoot
extends RefCounted
static func _candidates() -> Array:
var out: Array = []
var env := OS.get_environment("MT_ASSETS")
if env != "":
out.append(env)
# 挂载的 assets.zip(AssetPack)把资源放到 res://assets —— 移动端主路径。
# 用 asset_index.txt 作为「pack 已挂载」信号(空的 project/assets/ 占位目录不算)。
if FileAccess.file_exists("res://assets/asset_index.txt"):
out.append("res://assets")
out.append(ProjectSettings.globalize_path("res://../assets"))
var exe := OS.get_executable_path()
if exe != "":
var exe_dir := exe.get_base_dir() # .../mtgodot-poc.app/Contents/MacOS
out.append(exe_dir.path_join("../Resources/assets").simplify_path())
out.append(exe_dir.path_join("../../../assets").simplify_path()) # 与 .app 同级
out.append(exe_dir.path_join("assets")) # 与裸可执行文件同级
return out
static func path() -> String:
for c in _candidates():
if DirAccess.dir_exists_absolute(c):
return c
return ProjectSettings.globalize_path("res://../assets")
# 资源根下的子路径(用 path_join,跨平台)。
static func sub(rel: String) -> String:
return path().path_join(rel)
# 资源目录是否真的存在(缺资源的测试据此跳过)。
static func available() -> bool:
return DirAccess.dir_exists_absolute(path())
# --- 40250 client (pack/Index + .eix/.epk), docs/PORT-PLAN.md 2R item 6 / 2D ---
# 解析顺序:显式覆盖、macOS .app 内的自包含资源、开发树。
static func client_path() -> String:
var env := OS.get_environment("MT_40250_CLIENT")
if env != "":
return env
var exe := OS.get_executable_path()
if exe != "":
var bundled := exe.get_base_dir().path_join("../Resources/40250/Client").simplify_path()
if FileAccess.file_exists(bundled.path_join("pack/Index")):
return bundled
var repo_client := ProjectSettings.globalize_path("res://../Client").simplify_path()
if FileAccess.file_exists(repo_client.path_join("pack/Index")):
return repo_client
return ProjectSettings.globalize_path("res://../../40250/Server Client TMP4/Client").simplify_path()
# 按 40250 PackInitialize("pack") 注册 <client>/pack;只注册一次,之后返回首次结果。
static func pack_ready() -> bool:
if not ClassDB.class_exists("Metin2Pack"):
return false
return bool(ClassDB.class_call_static("Metin2Pack", "initialize", client_path()))
# 40250 的 locale 文件(CPythonApplication::Create: "%s/item_proto" % LocalePath,LocalePath = "locale/<lang>"),
# 走 pack://;该语言没有时回退 en。pack 没就绪返回 ""。
static func locale_file(name: String, lang: String = "en") -> String:
if not pack_ready():
return ""
for l in [lang, "en"]:
var p := "pack://locale/%s/%s" % [l, name]
if bool(ClassDB.class_call_static("Metin2Pack", "exists", "locale/%s/%s" % [l, name])):
return p
return ""
-1
View File
@@ -1 +0,0 @@
uid://debiskaw74tdw
View File
-358
View File
@@ -1,358 +0,0 @@
# audio_driver.gd —— 桥接 40250 Python/MilesLib 音频调用至 Godot 引擎。
# 每帧通过 Metin2PythonHost.audio_commands() 拉取原生端产生的音频播放/音量指令并执行。
class_name AudioDriver
extends Node
const AssetRoot = preload("res://asset_root.gd")
var _cache := {} # 绝对路径 -> AudioStream
var _bgm_root: String
var _sound_roots: PackedStringArray = []
var _bgm: Array[AudioStreamPlayer] = [] # 40250 CSoundManagerStream has three music slots.
var _bgm_names: Array[String] = []
var _bgm_volumes: Array[float] = []
var _bgm_fades := {} # slot -> {target, speed, mode}
var _ui_pool: Array[AudioStreamPlayer] = []
var _ui_next := 0
var master_sound := 1.0
var master_music := 1.0
# 3D 循环音效实例:id -> AudioStreamPlayer3D;所有 3D 播放器用于 StopAll。
var _active_3d := {}
var _all_3d: Array[AudioStreamPlayer3D] = []
func _ready() -> void:
_init_roots()
_init_players()
func _init_roots() -> void:
var client := AssetRoot.client_path()
var assets := AssetRoot.path()
# BGM 根目录:优先 Client/BGM,次选 assets/BGM
if DirAccess.dir_exists_absolute(client.path_join("BGM")):
_bgm_root = client.path_join("BGM")
elif DirAccess.dir_exists_absolute(assets.path_join("BGM")):
_bgm_root = assets.path_join("BGM")
else:
_bgm_root = client.path_join("BGM")
# 音效查找根目录
var candidate_roots := [
client.path_join("sound"),
client.path_join("Sound/sound"),
assets.path_join("Sound/sound"),
assets.path_join("sound2/sound"),
assets.path_join("sound_m/sound"),
assets.path_join("metin2_patch_w20_sound/sound"),
assets.path_join("Sound"),
assets,
client,
]
for r in candidate_roots:
if DirAccess.dir_exists_absolute(r) and not _sound_roots.has(r):
_sound_roots.append(r)
func _init_players() -> void:
for i in 3:
var pl := AudioStreamPlayer.new()
pl.bus = "Master"
pl.name = "BgmPlayer%d" % i
add_child(pl)
_bgm.append(pl)
_bgm_names.append("")
_bgm_volumes.append(0.0)
# 16 个 2D 音效池播放器
for i in 16:
var pl := AudioStreamPlayer.new()
pl.bus = "Master"
pl.name = "UiPlayer%d" % i
add_child(pl)
_ui_pool.append(pl)
func _process(_delta: float) -> void:
if not ClassDB.class_exists("Metin2PythonHost") or not Metin2PythonHost.is_running():
return
var commands: Array = Metin2PythonHost.audio_commands()
for cmd in commands:
var type: String = cmd.get("type", "")
match type:
"play_sound_2d":
play_sound_2d(cmd.get("filename", ""), cmd.get("volume", 1.0))
"play_sound_3d":
play_sound_3d(cmd.get("filename", ""), Vector3(cmd.get("x", 0.0) / 100.0, cmd.get("z", 0.0) / 100.0, -cmd.get("y", 0.0) / 100.0), cmd.get("volume", 1.0), cmd.get("id", 0), cmd.get("play_count", 1))
"stop_sound_3d":
stop_sound_3d(cmd.get("id", 0))
"stop_all_sound_3d":
stop_all_sound_3d()
"set_sound_volume_3d":
set_sound_volume_3d(cmd.get("id", 0), cmd.get("volume", 1.0))
"play_music":
play_music(cmd.get("filename", ""), cmd.get("volume", 1.0))
"fade_in_music":
fade_in_music(cmd.get("filename", ""), cmd.get("volume", 1.0), cmd.get("speed", 0.016))
"fade_out_music":
fade_out_music(cmd.get("filename", ""), cmd.get("speed", 0.016))
"fade_limit_out_music":
fade_limit_out_music(cmd.get("filename", ""), cmd.get("volume", 0.0), cmd.get("speed", 0.016))
"fade_out_all_music":
fade_out_all_music()
"set_sound_volume":
set_sound_volume(cmd.get("volume", 1.0))
"set_music_volume":
set_music_volume(cmd.get("volume", 1.0))
_update_music_fades()
# --- 音效路径检索与加载 ---------------------------------------------------
func _find_sound(rel: String) -> String:
if rel.is_empty():
return ""
rel = rel.replace("\\", "/").strip_edges()
if rel.begins_with("d:/ymir work/") or rel.begins_with("D:/ymir work/"):
rel = rel.substr(13)
elif rel.begins_with("d:/") or rel.begins_with("D:/"):
rel = rel.substr(3)
# 补全扩展名候选
var cands := PackedStringArray([rel])
if rel.get_extension().is_empty():
cands = PackedStringArray([rel + ".wav", rel + ".mp3", rel + ".ogg"])
# 40250's CEterPackManager is the authority for sounds, as it is for models and UI images.
if ClassDB.class_exists("Metin2Pack") and Metin2Pack.is_ready():
for c in cands:
if Metin2Pack.exists(c):
return "pack://" + c
if not c.begins_with("sound/") and Metin2Pack.exists("sound/" + c):
return "pack://sound/" + c
if FileAccess.file_exists(rel):
return rel
# 开发期散文件兜底。
for root in _sound_roots:
for c in cands:
var p := root.path_join(c)
if FileAccess.file_exists(p):
return p
# 若路径带 sound/,剥掉后再试一次(防止 root 自身已带 sound/)
if c.begins_with("sound/"):
var p_sub := root.path_join(c.substr(6))
if FileAccess.file_exists(p_sub):
return p_sub
return ""
func _load_stream(path: String) -> AudioStream:
if _cache.has(path):
return _cache[path]
var s: AudioStream = null
var ext := path.get_extension().to_lower()
var bytes: PackedByteArray
if path.begins_with("pack://"):
bytes = Metin2Pack.get_bytes(path.substr(7))
else:
bytes = FileAccess.get_file_as_bytes(path)
if bytes.is_empty():
return null
if ext == "mp3":
var m := AudioStreamMP3.new()
m.data = bytes
s = m if m.data.size() > 0 else null
elif ext == "ogg":
s = AudioStreamOggVorbis.load_from_buffer(bytes)
elif ext == "wav":
s = AudioStreamWAV.load_from_buffer(bytes)
_cache[path] = s
return s
# --- 2D 音效播放 -----------------------------------------------------------
func play_sound_2d(rel: String, volume_scale := 1.0) -> void:
var path := _find_sound(rel)
if path.is_empty():
return
var stream := _load_stream(path)
if stream == null:
return
if _ui_pool.is_empty():
return
var pl := _ui_pool[_ui_next]
_ui_next = (_ui_next + 1) % _ui_pool.size()
pl.stream = stream
# SoundManager already applied the current master volume to this command.
var vol: float = clampf(volume_scale, 0.0, 1.0)
pl.volume_db = linear_to_db(vol) if vol > 0.001 else -80.0
pl.play()
# --- 3D 空间音效播放 -------------------------------------------------------
func play_sound_3d(rel: String, pos: Vector3, volume_scale := 1.0, sound_id := 0, play_count := 1) -> void:
var path := _find_sound(rel)
if path.is_empty():
return
var stream := _load_stream(path)
if stream == null:
return
var pl := AudioStreamPlayer3D.new()
pl.stream = stream
pl.unit_size = 8.0
pl.max_distance = 80.0
var vol: float = clampf(volume_scale, 0.0, 1.0)
pl.volume_db = linear_to_db(vol) if vol > 0.001 else -80.0
add_child(pl)
_all_3d.append(pl)
pl.global_position = pos
pl.play()
if sound_id > 0:
_active_3d[sound_id] = pl
var state := {"remaining": play_count}
pl.finished.connect(func():
if state.remaining == 0 or state.remaining > 1:
if state.remaining > 1:
state.remaining -= 1
pl.play()
return
_all_3d.erase(pl)
if sound_id > 0 and _active_3d.get(sound_id) == pl:
_active_3d.erase(sound_id)
pl.queue_free())
func set_sound_volume_3d(sound_id: int, volume: float) -> void:
var pl: AudioStreamPlayer3D = _active_3d.get(sound_id)
if pl != null and is_instance_valid(pl):
var level := clampf(volume, 0.0, 1.0)
pl.volume_db = linear_to_db(level) if level > 0.001 else -80.0
func stop_sound_3d(sound_id: int) -> void:
if _active_3d.has(sound_id):
var pl: AudioStreamPlayer3D = _active_3d[sound_id]
if is_instance_valid(pl):
pl.stop()
_all_3d.erase(pl)
pl.queue_free()
_active_3d.erase(sound_id)
func stop_all_sound_3d() -> void:
for pl in _all_3d:
if is_instance_valid(pl):
pl.stop()
pl.queue_free()
_all_3d.clear()
_active_3d.clear()
# --- BGM 背景音乐播放 -----------------------------------------------------
func _music_stream(name: String) -> AudioStream:
var path := name.replace("\\", "/")
if not path.is_absolute_path():
var direct := _bgm_root.path_join(name)
if FileAccess.file_exists(direct):
path = direct
elif FileAccess.file_exists(direct + ".mp3"):
path = direct + ".mp3"
else:
path = _find_sound(name)
return _load_stream(path)
func _music_slot(name: String) -> int:
var normalized := name.replace("\\", "/").to_lower()
for i in _bgm_names.size():
if _bgm_names[i].replace("\\", "/").to_lower() == normalized and _bgm[i].playing:
return i
return -1
func _set_music_volume(slot: int, volume: float) -> void:
_bgm_volumes[slot] = clampf(volume, 0.0, 1.0)
_bgm[slot].volume_db = linear_to_db(_bgm_volumes[slot]) if _bgm_volumes[slot] > 0.001 else -80.0
func _start_music(slot: int, name: String, volume: float) -> bool:
var stream := _music_stream(name)
if stream == null:
return false
if stream is AudioStreamMP3:
stream.loop = true
_bgm[slot].stop()
_bgm[slot].stream = stream
_bgm_names[slot] = name
_set_music_volume(slot, volume)
_bgm[slot].play()
return true
func play_music(name: String, volume_scale := 1.0) -> void:
if name.is_empty():
return
# 40250 PlayMusic(name) always replaces stream slot 0.
_bgm_fades.erase(0)
_start_music(0, name, volume_scale)
func fade_in_music(name: String, target_volume := 1.0, speed := 0.016) -> void:
if name.is_empty():
return
var slot := _music_slot(name)
if slot < 0:
fade_out_all_music()
for i in _bgm.size():
if not _bgm[i].playing:
slot = i
break
if slot < 0 or not _start_music(slot, name, 0.0):
return
_bgm_fades[slot] = {"target": clampf(target_volume, 0.0, 1.0), "speed": speed, "mode": "in"}
func fade_out_music(name: String, speed := 0.016) -> void:
var slot := _music_slot(name)
if slot >= 0:
_bgm_fades[slot] = {"target": 0.0, "speed": speed, "mode": "out"}
func fade_limit_out_music(name: String, limit: float, speed := 0.016) -> void:
var slot := _music_slot(name)
if slot >= 0:
_bgm_fades[slot] = {"target": clampf(limit, 0.0, 1.0), "speed": speed, "mode": "limit"}
func fade_out_all_music() -> void:
for i in _bgm.size():
if _bgm[i].playing:
_bgm_fades[i] = {"target": 0.0, "speed": 0.01, "mode": "out"}
func _update_music_fades() -> void:
# 40250 advances fVolume by fVolumeSpeed once per SoundManager::Update frame.
for slot in _bgm_fades.keys():
if not _bgm[slot].playing:
_bgm_fades.erase(slot)
_bgm_names[slot] = ""
continue
var fade: Dictionary = _bgm_fades[slot]
var target: float = fade.target
var step: float = fade.speed
var volume: float = _bgm_volumes[slot]
match fade.mode:
"in":
volume = minf(target, volume + step)
"limit":
volume = maxf(target, volume - step)
"out":
volume = maxf(0.0, volume - step)
_set_music_volume(slot, volume)
if (fade.mode == "in" and volume >= target) or (fade.mode != "in" and volume <= target):
if fade.mode == "out":
_bgm[slot].stop()
_bgm_names[slot] = ""
_bgm_fades.erase(slot)
# --- 音量控制 -------------------------------------------------------------
func set_sound_volume(v: float) -> void:
master_sound = clampf(v, 0.0, 1.0)
func set_music_volume(v: float) -> void:
master_music = clampf(v, 0.0, 1.0)
for i in _bgm.size():
if _bgm[i].playing and not (_bgm_fades.has(i) and _bgm_fades[i].mode == "out"):
_set_music_volume(i, master_music)
-1
View File
@@ -1 +0,0 @@
uid://vqw8iy17y4m0
-81
View File
@@ -1,81 +0,0 @@
extends SceneTree
var failures := 0
func _initialize() -> void:
call_deferred("_run")
func _check(ok: bool, detail: String) -> void:
if not ok:
failures += 1
printerr("FAIL: " + detail)
func _run() -> void:
_check(AssetRoot.pack_ready(), "40250 pack unavailable")
var driver: Node = preload("res://audio_driver.gd").new()
root.add_child(driver)
var sound := "sound/ambience/waterfall.wav"
var path: String = driver._find_sound(sound)
_check(path == "pack://" + sound, "sound must resolve through 40250 pack")
var stream: AudioStream = driver._load_stream(path)
_check(stream != null, "packed WAV must decode")
if stream != null:
driver.set_sound_volume(0.5)
driver.play_sound_2d(sound, 0.5)
var player: AudioStreamPlayer = driver._ui_pool[0]
_check(absf(player.volume_db - linear_to_db(0.5)) < 0.01,
"sound volume was applied more than once")
driver.play_sound_3d(sound, Vector3.ZERO, 0.5)
_check(driver._all_3d.size() == 1, "3D sound not tracked")
driver.play_sound_3d(sound, Vector3.ZERO, 0.5, 17, 0)
driver.set_sound_volume_3d(17, 0.25)
_check(absf(driver._active_3d[17].volume_db - linear_to_db(0.25)) < 0.01,
"indexed 3D sound volume did not update")
_check(driver._all_3d.size() == 2, "looping 3D sound not tracked")
driver._active_3d[17].finished.emit()
_check(driver._active_3d.has(17), "zero-loop-count ambience did not restart")
driver.play_sound_3d(sound, Vector3.ZERO, 0.5, 18, 2)
var twice: AudioStreamPlayer3D = driver._active_3d[18]
twice.finished.emit()
_check(driver._active_3d.has(18), "two-play ambience stopped after first play")
twice.finished.emit()
_check(not driver._active_3d.has(18), "two-play ambience did not finish")
driver.stop_sound_3d(17)
_check(not driver._active_3d.has(17), "StopSound3D left indexed sound running")
driver.stop_all_sound_3d()
_check(driver._all_3d.is_empty(), "StopAllSound3D left a player running")
driver.play_music("login_window.mp3", 0.8)
_check(driver._music_slot("login_window.mp3") == 0, "PlayMusic did not use slot 0")
driver.fade_limit_out_music("login_window.mp3", 0.2, 0.1)
for i in 8:
driver._update_music_fades()
_check(absf(driver._bgm_volumes[0] - 0.2) < 0.001,
"FadeLimitOutMusic did not retain the requested limit")
driver.fade_limit_out_music("login_window.mp3", 0.7, 0.1)
driver._update_music_fades()
_check(absf(driver._bgm_volumes[0] - 0.7) < 0.001,
"40250 limit fade must clamp immediately when already below the limit")
driver.fade_limit_out_music("login_window.mp3", 0.2, 0.5)
driver._update_music_fades()
driver.fade_out_music("another_track.mp3", 0.1)
_check(not driver._bgm_fades.has(1), "FadeOutMusic affected an unrelated track")
driver.fade_out_music("login_window.mp3", 0.1)
for i in 2:
driver._update_music_fades()
_check(not driver._bgm[0].playing, "FadeOutMusic did not stop named track")
driver.fade_in_music("characterselect.mp3", 0.3, 0.1)
for i in 3:
driver._update_music_fades()
_check(absf(driver._bgm_volumes[0] - 0.3) < 0.001,
"FadeInMusic did not advance by the requested frame step")
for player in driver._bgm:
player.stop()
player.stream = null
for player in driver._ui_pool:
player.stop()
player.stream = null
driver._cache.clear()
driver.free()
await process_frame
print("PASS: audio_driver_test" if failures == 0 else "FAIL: audio_driver_test")
quit(0 if failures == 0 else 1)
-1
View File
@@ -1 +0,0 @@
uid://dfbxpg1q8a0q8
-164
View File
@@ -1,164 +0,0 @@
# Bounded, read-only release probe on the same native 40250 path as normal play.
# Credentials stay in the process environment and are never written to the report.
extends Node
var _cases: Array[Dictionary] = []
var _failed := false
func _ready() -> void:
call_deferred("_run")
func _check(ok: bool, name: String) -> bool:
_cases.append({"id": name, "status": "PASS" if ok else "FAIL"})
if not ok:
_failed = true
printerr("BETA_PROBE: FAIL " + name)
return ok
func _py(source: String) -> bool:
return Metin2PythonHost.run_line(source) == ""
func _until(seconds: float, condition: Callable) -> bool:
var deadline := Time.get_ticks_msec() + int(seconds * 1000.0)
while Time.get_ticks_msec() < deadline:
if condition.call():
return true
await get_tree().process_frame
return condition.call()
func _key_state(keycode: Key, unicode: int, pressed: bool) -> void:
var event := InputEventKey.new()
event.keycode = keycode
event.physical_keycode = keycode
event.unicode = unicode
event.pressed = pressed
get_tree().root.push_input(event)
func _key(keycode: Key, unicode: int) -> void:
_key_state(keycode, unicode, true)
_key_state(keycode, unicode, false)
func _type(value: String) -> void:
for i in value.length():
var codepoint := value.unicode_at(i)
_key(OS.find_keycode_from_string(value[i].to_upper()), codepoint)
func _finish(output: String, report: Dictionary) -> void:
report["cases"] = _cases
report["status"] = "FAIL" if _failed else "PASS"
var file := FileAccess.open(output.path_join("client-report.json"), FileAccess.WRITE)
if file == null:
printerr("BETA_PROBE: cannot write client report")
get_tree().quit(1)
return
file.store_string(JSON.stringify(report, " "))
file.close()
print("BETA_PROBE: " + report["status"])
if not _py("import app\napp.Exit()"):
Metin2PythonHost.stop()
get_tree().quit(1)
return
# PythonUISurface observes app.Exit, stops the interpreter and ends this process.
func _run() -> void:
printerr("BETA_STAGE: start")
var output := OS.get_environment("MT_BETA_OUTPUT")
if output.is_empty() or not output.is_absolute_path():
printerr("BETA_PROBE: MT_BETA_OUTPUT must be an absolute directory")
get_tree().quit(2)
return
DirAccess.make_dir_recursive_absolute(output)
var report := {"schema_version": 1, "route": "native_40250", "map": "", "draw_count": 0}
var host := OS.get_environment("MT_LIVE_HOST")
var login := OS.get_environment("MT_LIVE_LOGIN")
var password := OS.get_environment("MT_LIVE_PASSWORD")
if not _check(not host.is_empty() and not login.is_empty() and not password.is_empty(), "credentials_present"):
_finish(output, report)
return
if OS.get_environment("MT_BETA_REQUIRE_BUNDLE") == "1":
var bundle := OS.get_executable_path().get_base_dir().path_join("../Resources/40250/Client").simplify_path()
if not _check(AssetRoot.client_path() == bundle, "bundled_client_selected"):
_finish(output, report)
return
if not _check(AssetRoot.pack_ready() and Metin2PythonHost.is_app_looping(), "bundled_pack_and_python_loop"):
_finish(output, report)
return
if not _check(await _until(20, func(): return _py(
"import __main__, networkModule, introLogin, introSelect, game\n"
+ "__main__._stream = [o for o in __import__('gc').get_objects() if isinstance(o, networkModule.MainStream)][0]\n"
+ "assert isinstance(_stream.curPhaseWindow, introLogin.LoginWindow)")), "login_window"):
_finish(output, report)
return
printerr("BETA_STAGE: login_window")
if not _check(_py("import os\n"
+ "_env = os.environ\n"
+ "_slot = int(_env.get('MT_LIVE_SLOT', '0'))\n"
+ "_stream.SetConnectInfo(_env['MT_LIVE_HOST'], int(_env.get('MT_LIVE_GAME_PORT', '13000')), "
+ "_env['MT_LIVE_HOST'], int(_env.get('MT_LIVE_AUTH_PORT', '11000')))\n"
+ "_w = _stream.curPhaseWindow\n"
+ "_w._LoginWindow__OpenLoginBoard()\n"
+ "_w.idEditLine.SetText('')\n"
+ "_w.pwdEditLine.SetText('')\n"
+ "_w.idEditLine.SetFocus()"), "login_board"):
_finish(output, report)
return
_type(login)
_key(KEY_TAB, 0)
_type(password)
if not _check(_py("assert _w.idEditLine.GetText() == _env['MT_LIVE_LOGIN']\n"
+ "assert _w.pwdEditLine.GetText() == _env['MT_LIVE_PASSWORD']\n"
+ "del _env, _w"), "keyboard_input"):
_finish(output, report)
return
_key(KEY_ENTER, 0)
printerr("BETA_STAGE: login_submitted")
var selecting := await _until(30, func(): return _py(
"assert isinstance(_stream.curPhaseWindow, introSelect.SelectCharacterWindow)"))
if not _check(selecting, "character_select"):
_finish(output, report)
return
printerr("BETA_STAGE: character_select")
if not _check(_py("import net\n"
+ "assert net.GetAccountCharacterSlotDataString(_slot, net.ACCOUNT_CHARACTER_SLOT_NAME)\n"
+ "_stream.curPhaseWindow.SelectSlot(_slot)\n"
+ "_stream.curPhaseWindow.StartGame()"), "selected_character"):
_finish(output, report)
return
var in_game := await _until(70, func(): return _py(
"assert isinstance(_stream.curPhaseWindow, game.GameWindow) and _stream.curPhaseWindow.IsShow()"))
if not _check(in_game, "game_window"):
_finish(output, report)
return
printerr("BETA_STAGE: game_window")
var world: Node = get_parent().get_node("Python3DSurface")
_check(await _until(10, func(): return world.draw_count > 0), "character_draws")
_check(not Metin2PythonHost.current_map_name().is_empty(), "map_selected")
_check(int(world.terrain_report.get("chunks_splatted", 0)) > 0, "terrain_textured")
_check(_py("assert net.IsConnect()"), "network_connected")
if _check(_py("import player\n_beta_start_pos = player.GetMainCharacterPosition()"), "movement_start_position"):
printerr("BETA_STAGE: move_start")
_key_state(KEY_UP, 0, true)
await get_tree().create_timer(1.5).timeout
_key_state(KEY_UP, 0, false)
await get_tree().create_timer(0.5).timeout
_check(_py("_beta_end_pos = player.GetMainCharacterPosition()\n"
+ "assert abs(_beta_end_pos[0] - _beta_start_pos[0])"
+ " + abs(_beta_end_pos[1] - _beta_start_pos[1]) > 20"), "movement_position_changed")
printerr("BETA_STAGE: move_end")
# Let the loading fade finish before assessing what a player actually sees.
await get_tree().create_timer(2.0).timeout
report["draw_count"] = world.draw_count
report["map"] = Metin2PythonHost.current_map_name()
report["terrain_chunks"] = int(world.terrain_report.get("chunks_splatted", 0))
if DisplayServer.get_name() != "headless":
await RenderingServer.frame_post_draw
var screenshot := get_viewport().get_texture().get_image()
_check(screenshot.save_png(output.path_join("game.png")) == OK, "screenshot")
var lit := 0
for y in range(screenshot.get_height() / 4, screenshot.get_height() * 3 / 4, 12):
for x in range(screenshot.get_width() / 4, screenshot.get_width() * 3 / 4, 12):
if screenshot.get_pixel(x, y).get_luminance() > 0.05:
lit += 1
report["centre_lit_samples"] = lit
_check(lit > 30, "scene_visible_after_loading")
_finish(output, report)
-1
View File
@@ -1 +0,0 @@
uid://b0y8dh6cv657q
-38
View File
@@ -1,38 +0,0 @@
[configuration]
entry_symbol = "mtgodot_library_init"
compatibility_minimum = "4.7"
reloadable = true
[libraries]
macos.debug = "res://bin/libmtgodot.macos.template_debug.dylib"
macos.release = "res://bin/libmtgodot.macos.template_release.dylib"
macos.editor = "res://bin/libmtgodot.macos.template_debug.dylib"
macos.editor.arm64 = "res://bin/libmtgodot.macos.template_debug.dylib"
ios.debug = "res://bin/libmtgodot.ios.template_debug.a"
ios.release = "res://bin/libmtgodot.ios.template_release.a"
android.debug.arm64 = "res://bin/libmtgodot.android.template_debug.arm64.so"
android.release.arm64 = "res://bin/libmtgodot.android.template_release.arm64.so"
[dependencies]
; iOS can't dlopen: the extension archive above is linked into the app at export
; time, and it needs every static lib it was built against on the link line.
; `./build-ios.sh` stages these into project/bin/ios/. See docs/PLATFORMS.md (F1).
ios.debug = {
"res://bin/ios/libgodot-cpp.ios.template_debug.arm64.a" : "",
"res://bin/ios/liblibgr2.a" : "",
"res://bin/ios/libxr_formats.a" : "",
"res://bin/ios/libmtnet.a" : "",
"res://bin/ios/libsodium.a" : ""
}
ios.release = {
"res://bin/ios/libgodot-cpp.ios.template_release.arm64.a" : "",
"res://bin/ios/liblibgr2.a" : "",
"res://bin/ios/libxr_formats.a" : "",
"res://bin/ios/libmtnet.a" : "",
"res://bin/ios/libsodium.a" : ""
}
-1
View File
@@ -1 +0,0 @@
uid://hc7fxxrjvpr8
-58
View File
@@ -1,58 +0,0 @@
# client_main —— 打包客户端的入口场景根(main_scene = res://client_main.tscn)。
#
# 走 40250 原生 port 路径:40250 system.py 在内嵌解释器里跑,界面画在
# python_ui_surface.gd、RenderGame 的 3D 绘制画在 python_3d_surface.gd。
# 方向键/鼠标点地走动走 40250 原路径(2V3);app.Exit() 退出进程。
# 资源目录由 AssetRoot 决定:40250 Client/pack 资源包。
extends Node
func _ready() -> void:
if OS.get_environment("MT_TEST_MODE") == "particle_exit":
add_child(preload("res://particle_exit_test.gd").new())
return
# MT_TEST_MODE=python:在 app 进程里起内嵌解释器跑 system.py(PORT-PLAN 批次 2P step 4)。
# 导出模板不接受 --script,这是在真机 APK/IPA 里跑这条检查的唯一入口。
if OS.get_environment("MT_TEST_MODE") == "python":
AssetPack.ensure()
var fail: int = preload("res://python_host_check.gd").new().run()
if fail == 0:
print("PASS: python_host_check (app 进程内 system.py)")
else:
printerr("%d check(s) failed" % fail)
get_tree().quit(1 if fail else 0)
return
# 原生 port 路径:40250 system.py 的界面直接画在 Godot Canvas 上(PORT-PLAN 2V0-f),
# Logo → 登录 → 选人 → 进游戏,RenderGame 的 3D 绘制由 python_3d_surface.gd 画出(2V2-e),
# 方向键/鼠标点地走动走 40250 原路径(2V3);app.Exit() 退出进程。
if OS.has_environment("MT_PORT_PATH") and OS.get_environment("MT_PORT_PATH") == "legacy":
push_warning("[client] MT_PORT_PATH=legacy 已废弃并移除,40250 原生 port 路径已接管主循环")
_start_port_route()
# 启动 40250 原生 port 路径
func _start_port_route() -> void:
if not (ClassDB.class_exists("Metin2PythonHost") and AssetRoot.pack_ready()):
printerr("[client] port 路由缺少 Metin2PythonHost 或 40250 Client/pack(%s)" % AssetRoot.client_path())
get_tree().quit(1)
return
var surface: Control = preload("res://python_ui_surface.gd").new()
surface.name = "PythonUISurface"
add_child(surface)
# The UI surface runs CPythonApplication::Process in its _process; the 3D surface reads that
# frame's draws after it, so it comes second in the tree.
var world: Node3D = preload("res://python_3d_surface.gd").new()
world.name = "Python3DSurface"
add_child(world)
var audio: Node = preload("res://audio_driver.gd").new()
audio.name = "AudioDriver"
add_child(audio)
var err: String = surface.run_app()
if err != "":
printerr("port: " + err)
get_tree().quit(1)
return
if OS.get_environment("MT_TEST_MODE") == "beta_probe":
var probe: Node = preload("res://beta_live_probe.gd").new()
probe.name = "BetaLiveProbe"
add_child(probe)
-1
View File
@@ -1 +0,0 @@
uid://d384dorgv24yv
-6
View File
@@ -1,6 +0,0 @@
[gd_scene load_steps=2 format=3 uid="uid://b0mtgdclientmain0"]
[ext_resource type="Script" path="res://client_main.gd" id="1_client"]
[node name="ClientMain" type="Node"]
script = ExtResource("1_client")
-140
View File
@@ -1,140 +0,0 @@
extends SceneTree
const PythonUISurface = preload("res://python_ui_surface.gd")
const Python3DSurface = preload("res://python_3d_surface.gd")
var ui: Control
var world: Node3D
func _initialize() -> void:
call_deferred("run")
func _py(source: String) -> bool:
return Metin2PythonHost.run_line(source) == ""
func _pump_until(seconds: float, condition: Callable) -> bool:
var deadline := Time.get_ticks_msec() + int(seconds * 1000.0)
while Time.get_ticks_msec() < deadline:
if condition.call():
return true
await process_frame
return condition.call()
func run() -> void:
root.size = Vector2i(1024, 768)
world = Python3DSurface.new()
world.name = "Python3DSurface"
ui = PythonUISurface.new()
ui.name = "PythonUISurface"
root.add_child(ui)
root.add_child(world)
var err: String = ui.run_app()
if err != "":
print("ERROR: run_app: ", err)
quit(1)
return
await _pump_until(15, func(): return _py(
"import __main__, os, networkModule, introLogin, introSelect, game, player, background, chr, chrmgr\n"
+ "__main__._stream = [o for o in __import__('gc').get_objects() if isinstance(o, networkModule.MainStream)][0]"
))
await _pump_until(20, func(): return _py(
"assert isinstance(_stream.curPhaseWindow, introLogin.LoginWindow)"
))
var host = OS.get_environment("MT_LIVE_HOST")
var login = OS.get_environment("MT_LIVE_LOGIN")
var pwd = OS.get_environment("MT_LIVE_PASSWORD")
var auth_port = int(OS.get_environment("MT_LIVE_AUTH_PORT"))
var game_port = int(OS.get_environment("MT_LIVE_GAME_PORT"))
_py("_stream.SetConnectInfo('%s', %d, '%s', %d)\n" % [host, game_port, host, auth_port]
+ "_stream.curPhaseWindow.Connect('%s', '%s')" % [login, pwd])
await _pump_until(30, func(): return _py(
"assert isinstance(_stream.curPhaseWindow, introSelect.SelectCharacterWindow)"
))
_py("_stream.curPhaseWindow.SelectSlot(0)\n_stream.curPhaseWindow.StartGame()")
await _pump_until(30, func(): return _py(
"assert isinstance(_stream.curPhaseWindow, game.GameWindow) and _stream.curPhaseWindow.IsShow()"
))
for i in 60:
await process_frame
print("=== CAMERA WORLD & HEIGHT PROFILE ===")
var draws: Array = Metin2PythonHost.render3d_draws()
if not draws.is_empty():
var vm: PackedFloat32Array = draws[0]["view"]
var cam_inv: Transform3D = Python3DSurface.d3d_transform(vm).affine_inverse()
print("Camera world origin: ", cam_inv.origin)
print("Camera world basis: ", cam_inv.basis)
print("Camera looking direction: ", -cam_inv.basis.z)
for y in range(68200, 66200, -100):
var bg_h = Metin2PythonHost.evaluate("str(background.GetHeight(59700.0, %f))" % float(y))
var terrain_h = float(world.terrain.call("sample_height", 597.0, float(y) / 100.0)) * 100.0 if world.terrain else 0.0
print("Profile y=%d: bg_h=%s terrain_h=%.2f" % [y, bg_h, terrain_h])
print("Total draws: ", draws.size())
for d in draws:
var tex: String = d.get("texture0", "")
if tex.contains("warrior") or tex.contains("stray_dog") or tex.contains("goods"):
var wm: PackedFloat32Array = d["world"]
var vm: PackedFloat32Array = d["view"]
var xform: Transform3D = Python3DSurface.d3d_transform(wm)
var cam_xform: Transform3D = Python3DSurface.d3d_transform(Python3DSurface.multiply(wm, vm))
var min_z := 1e9
var max_z := -1e9
var min_local_z := 1e9
var max_local_z := -1e9
for pos in d["positions"]:
min_local_z = minf(min_local_z, pos.z)
max_local_z = maxf(max_local_z, pos.z)
var wp: Vector3 = xform * pos
min_z = minf(min_z, wp.z)
max_z = maxf(max_z, wp.z)
# Map position: (xform.origin.x, -xform.origin.y)
var map_x := xform.origin.x
var map_y := -xform.origin.y
var actor_z := xform.origin.z
var bg_h = Metin2PythonHost.evaluate("str(background.GetHeight(%f, %f))" % [map_x, map_y])
var terrain_h = float(world.terrain.call("sample_height", map_x / 100.0, map_y / 100.0)) * 100.0 if world.terrain else 0.0
print("Actor [%s]:" % tex.get_file())
print(" World origin: ", xform.origin, " (map_x=%.1f, map_y=%.1f, origin_z=%.1f)" % [map_x, map_y, actor_z])
print(" Local vert Z range: [%.2f, %.2f]" % [min_local_z, max_local_z])
print(" World vert Z range: [%.2f, %.2f]" % [min_z, max_z])
print(" Background Height: %s, Terrain Height: %.2f" % [bg_h, terrain_h])
print(" Vert_Z min vs Height: diff = %.2f cm" % (min_z - float(bg_h)))
print(" Origin_Z vs Height: diff = %.2f cm" % (actor_z - float(bg_h)))
print(" Camera-space origin: ", cam_xform.origin)
if tex.contains("stray_dog"):
print("--- DOG TERRAIN DETAIL ---")
var from_map := Transform3D(Basis(Vector3(100, 0, 0), Vector3(0, 0, 100), Vector3(0, -100, 0)), Vector3.ZERO)
var dog_world := xform.origin # (59700, -66400, 19851.5)
print("Dog 40250 world: ", dog_world)
for child in world.terrain.find_children("*", "MeshInstance3D", true, false):
var mi := child as MeshInstance3D
if mi.mesh is ArrayMesh:
var am := mi.mesh as ArrayMesh
var arrays := am.surface_get_arrays(0)
var verts: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX]
for v in verts:
var world_v: Vector3 = from_map * (mi.transform * v)
if abs(world_v.x - dog_world.x) < 300.0 and abs(world_v.y - dog_world.y) < 300.0:
print("Terrain local vert: ", v, " -> mi.xform*v: ", mi.transform * v, " -> from_map: ", world_v)
print("Diff Z (terrain world Z - dog world Z): ", world_v.z - dog_world.z)
break
quit(0)
-1
View File
@@ -1 +0,0 @@
uid://b5vx3h3lipsuf
-116
View File
@@ -1,116 +0,0 @@
extends SceneTree
const PythonUISurface = preload("res://python_ui_surface.gd")
const Python3DSurface = preload("res://python_3d_surface.gd")
var ui: Control
var world: Node3D
func _initialize() -> void:
call_deferred("run")
func _py(source: String) -> bool:
return Metin2PythonHost.run_line(source) == ""
func _pump_until(seconds: float, condition: Callable) -> bool:
var deadline := Time.get_ticks_msec() + int(seconds * 1000.0)
while Time.get_ticks_msec() < deadline:
if condition.call():
return true
await process_frame
return condition.call()
func run() -> void:
root.size = Vector2i(1024, 768)
world = Python3DSurface.new()
ui = PythonUISurface.new()
root.add_child(ui)
root.add_child(world)
ui.run_app()
await _pump_until(15, func(): return _py(
"import __main__, os, networkModule, introLogin, introSelect, game, player, background, chr, chrmgr\n"
+ "__main__._stream = [o for o in __import__('gc').get_objects() if isinstance(o, networkModule.MainStream)][0]"
))
await _pump_until(20, func(): return _py(
"assert isinstance(_stream.curPhaseWindow, introLogin.LoginWindow)"
))
var host = OS.get_environment("MT_LIVE_HOST")
var login = OS.get_environment("MT_LIVE_LOGIN")
var pwd = OS.get_environment("MT_LIVE_PASSWORD")
var auth_port = int(OS.get_environment("MT_LIVE_AUTH_PORT"))
var game_port = int(OS.get_environment("MT_LIVE_GAME_PORT"))
_py("_stream.SetConnectInfo('%s', %d, '%s', %d)\n" % [host, game_port, host, auth_port]
+ "_stream.curPhaseWindow.Connect('%s', '%s')" % [login, pwd])
await _pump_until(30, func(): return _py(
"assert isinstance(_stream.curPhaseWindow, introSelect.SelectCharacterWindow)"
))
_py("_stream.curPhaseWindow.SelectSlot(0)\n_stream.curPhaseWindow.StartGame()")
await _pump_until(30, func(): return _py(
"assert isinstance(_stream.curPhaseWindow, game.GameWindow) and _stream.curPhaseWindow.IsShow()"
))
for i in 60:
await process_frame
print("=== INVESTIGATING ALL 518 ACTORS ===")
var draws: Array = Metin2PythonHost.render3d_draws()
var actor_draws := {}
for d in draws:
var wm: PackedFloat32Array = d["world"]
var key := "%.1f,%.1f,%.1f" % [wm[12], wm[13], wm[14]]
if not actor_draws.has(key):
actor_draws[key] = []
actor_draws[key].append(d)
print("Unique actor positions: ", actor_draws.size())
var anomalies := []
for key in actor_draws:
var ds: Array = actor_draws[key]
var wm: PackedFloat32Array = ds[0]["world"]
var map_x := wm[12]
var map_y := -wm[13]
var actor_z := wm[14]
var bg_h = float(Metin2PythonHost.evaluate("str(background.GetHeight(%f, %f))" % [map_x, map_y]))
var terrain_h = float(world.terrain.call("sample_height", map_x / 100.0, map_y / 100.0)) * 100.0 if world.terrain else 0.0
var diff_bg = actor_z - bg_h
var diff_terrain = actor_z - terrain_h
# Find textures
var texs := []
var min_vert_z := 1e9
for d in ds:
var t: String = d.get("texture0", "")
if not t.is_empty():
texs.append(t.get_file())
for p in d["positions"]:
min_vert_z = minf(min_vert_z, actor_z + p.z)
if absf(diff_bg) > 5.0 or absf(diff_terrain) > 5.0:
anomalies.append({
"pos": [map_x, map_y, actor_z],
"bg_h": bg_h,
"terrain_h": terrain_h,
"diff_bg": diff_bg,
"diff_terrain": diff_terrain,
"vert_min_diff": min_vert_z - bg_h,
"textures": texs
})
print("Found %d anomalies with gap > 5cm!" % anomalies.size())
for a in anomalies.slice(0, 30):
print("Anomaly: pos=(%.1f, %.1f, %.1f), bg_h=%.1f, terrain_h=%.1f, diff_bg=%.1f, diff_terrain=%.1f, vert_min_diff=%.1f, tex=%s" % [
a["pos"][0], a["pos"][1], a["pos"][2], a["bg_h"], a["terrain_h"], a["diff_bg"], a["diff_terrain"], a["vert_min_diff"], a["textures"]
])
quit(0)
-1
View File
@@ -1 +0,0 @@
uid://xn0yhjq7h8d
-143
View File
@@ -1,143 +0,0 @@
extends SceneTree
const PythonUISurface = preload("res://python_ui_surface.gd")
const Python3DSurface = preload("res://python_3d_surface.gd")
var ui: Control
var world: Node3D
func _initialize() -> void:
call_deferred("run")
func _py(source: String) -> bool:
return Metin2PythonHost.run_line(source) == ""
func _key(keycode: Key, unicode: int, pressed: bool) -> void:
var event := InputEventKey.new()
event.keycode = keycode
event.physical_keycode = keycode
event.unicode = unicode
event.pressed = pressed
root.push_input(event)
func _press(keycode: Key) -> void:
_key(keycode, 0, true)
_key(keycode, 0, false)
func _type(text: String) -> void:
for i in text.length():
var ch := text.unicode_at(i)
var keycode := OS.find_keycode_from_string(text[i].to_upper())
_key(keycode, ch, true)
_key(keycode, ch, false)
func _pump_until(seconds: float, condition: Callable) -> bool:
var deadline := Time.get_ticks_msec() + int(seconds * 1000.0)
while Time.get_ticks_msec() < deadline:
if condition.call():
return true
await process_frame
return condition.call()
func run() -> void:
root.size = Vector2i(1024, 768)
world = Python3DSurface.new()
world.name = "Python3DSurface"
ui = PythonUISurface.new()
ui.name = "PythonUISurface"
root.add_child(ui)
root.add_child(world)
var err: String = ui.run_app()
if err != "":
print("ERROR: run_app failed: ", err)
quit(1)
return
await _pump_until(15, func(): return _py(
"import __main__, os, networkModule, introLogin, introSelect, game, player, background, chr, chrmgr\n"
+ "__main__._stream = [o for o in __import__('gc').get_objects() if isinstance(o, networkModule.MainStream)][0]"
))
await _pump_until(20, func(): return _py(
"assert isinstance(_stream.curPhaseWindow, introLogin.LoginWindow)"
))
_py(
"_env = os.environ\n"
+ "_slot = int(_env.get('MT_LIVE_SLOT', '0'))\n"
+ "_stream.SetConnectInfo(_env['MT_LIVE_HOST'], int(_env.get('MT_LIVE_GAME_PORT', '13000')), "
+ "_env['MT_LIVE_HOST'], int(_env.get('MT_LIVE_AUTH_PORT', '11000')))\n"
+ "_w = _stream.curPhaseWindow\n"
+ "_w._LoginWindow__OpenLoginBoard()\n"
+ "_w.idEditLine.SetText('')\n"
+ "_w.pwdEditLine.SetText('')\n"
+ "_w.idEditLine.SetFocus()"
)
_type(OS.get_environment("MT_LIVE_LOGIN"))
_press(KEY_TAB)
_type(OS.get_environment("MT_LIVE_PASSWORD"))
_py("del _env, _w")
_press(KEY_ENTER)
await _pump_until(30, func(): return _py(
"assert isinstance(_stream.curPhaseWindow, introSelect.SelectCharacterWindow)"
))
_py("_stream.curPhaseWindow.SelectSlot(_slot)\n_stream.curPhaseWindow.StartGame()")
await _pump_until(30, func(): return _py(
"assert isinstance(_stream.curPhaseWindow, game.GameWindow) and _stream.curPhaseWindow.IsShow()"
))
for i in 30:
await process_frame
print("=== DEBUG HEIGHT REPORT ===")
var test_points = [
Vector2(59700, 68200),
Vector2(59700, 66400),
Vector2(60100, 66400),
Vector2(50000, 50000),
Vector2(30000, 30000),
Vector2(70000, 70000),
]
for pt in test_points:
var bg_h = Metin2PythonHost.evaluate("str(background.GetHeight(%f, %f))" % [pt.x, pt.y])
var tm_h = float(world.terrain.call("sample_height", pt.x / 100.0, pt.y / 100.0)) * 100.0 if world.terrain else 0.0
print("Point (%.0f, %.0f): 40250_bg_h=%s, Metin2World_h=%.1f, diff=%.1f" % [
pt.x, pt.y, bg_h, tm_h, float(bg_h) - tm_h
])
# Inspect terrain mesh triangles around dog
print("=== TERRAIN MESH VERTICES AROUND DOG (59700, -66400) ===")
# In camera space (since Camera3D is at origin):
# Dog world pos in 40250: (59700, -66400, 19851.5)
# What is dog in camera space?
var draws = Metin2PythonHost.render3d_draws()
for i in draws.size():
var d = draws[i]
var tex: String = d.get("texture0", "")
if tex.contains("stray_dog"):
var wm: PackedFloat32Array = d.get("world", PackedFloat32Array())
var vm: PackedFloat32Array = d.get("view", PackedFloat32Array())
var xform: Transform3D = Python3DSurface.d3d_transform(Python3DSurface.multiply(wm, vm))
print("DOG camera-space transform origin: ", xform.origin)
# Now let's check terrain mesh global transform and vertices:
for child in world.terrain.find_children("*", "MeshInstance3D", true, false):
var mi := child as MeshInstance3D
if mi.mesh is ArrayMesh:
var am := mi.mesh as ArrayMesh
var arrays := am.surface_get_arrays(0)
var verts: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX]
for v in verts:
var gv: Vector3 = mi.global_transform * v
# Dog in camera space is around xform.origin. Let's find terrain vertices within 200cm:
if abs(gv.x - xform.origin.x) < 200.0 and abs(gv.z - xform.origin.z) < 200.0:
print("Terrain vertex near dog in camera-space: ", gv, " dog origin: ", xform.origin, " diff_y (height in cam space): ", gv.y - xform.origin.y)
break
break
quit(0)
-1
View File
@@ -1 +0,0 @@
uid://mpcddwfiipkc
-71
View File
@@ -1,71 +0,0 @@
extends SceneTree
const PythonUISurface = preload("res://python_ui_surface.gd")
const Python3DSurface = preload("res://python_3d_surface.gd")
var ui: Control
var world: Node3D
func _initialize() -> void:
call_deferred("run")
func _py(source: String) -> bool:
return Metin2PythonHost.run_line(source) == ""
func _pump_until(seconds: float, condition: Callable) -> bool:
var deadline := Time.get_ticks_msec() + int(seconds * 1000.0)
while Time.get_ticks_msec() < deadline:
if condition.call():
return true
await process_frame
return condition.call()
func run() -> void:
root.size = Vector2i(1024, 768)
world = Python3DSurface.new()
world.name = "Python3DSurface"
ui = PythonUISurface.new()
ui.name = "PythonUISurface"
root.add_child(ui)
root.add_child(world)
var err: String = ui.run_app()
if err != "":
print("ERROR: run_app: ", err)
quit(1)
return
await _pump_until(15, func(): return _py(
"import __main__, os, networkModule, introLogin, introSelect, game, player, background, chr, chrmgr\n"
+ "__main__._stream = [o for o in __import__('gc').get_objects() if isinstance(o, networkModule.MainStream)][0]"
))
await _pump_until(20, func(): return _py(
"assert isinstance(_stream.curPhaseWindow, introLogin.LoginWindow)"
))
var host = OS.get_environment("MT_LIVE_HOST")
var login = OS.get_environment("MT_LIVE_LOGIN")
var pwd = OS.get_environment("MT_LIVE_PASSWORD")
var auth_port = int(OS.get_environment("MT_LIVE_AUTH_PORT"))
var game_port = int(OS.get_environment("MT_LIVE_GAME_PORT"))
_py("_stream.SetConnectInfo('%s', %d, '%s', %d)\n" % [host, game_port, host, auth_port]
+ "_stream.curPhaseWindow.Connect('%s', '%s')" % [login, pwd])
await _pump_until(30, func(): return _py(
"assert isinstance(_stream.curPhaseWindow, introSelect.SelectCharacterWindow)"
))
print("=== IN INTRO SELECT PHASE ===")
for i in 60:
await process_frame
var cmds: Array = Metin2PythonHost.ui_render_commands()
print("UI commands count: ", cmds.size())
var file = FileAccess.open("/tmp/select_ui_cmds.json", FileAccess.WRITE)
file.store_string(JSON.stringify(cmds, " "))
file.close()
print("Saved /tmp/select_ui_cmds.json")
quit(0)
-1
View File
@@ -1 +0,0 @@
uid://c0k854rmu55u7
-70
View File
@@ -1,70 +0,0 @@
extends SceneTree
const PythonUISurface = preload("res://python_ui_surface.gd")
const Python3DSurface = preload("res://python_3d_surface.gd")
func _initialize() -> void:
call_deferred("run")
func _py(source: String) -> bool:
return Metin2PythonHost.run_line(source) == ""
func _pump_until(seconds: float, condition: Callable) -> bool:
var deadline := Time.get_ticks_msec() + int(seconds * 1000.0)
while Time.get_ticks_msec() < deadline:
if condition.call():
return true
await process_frame
return condition.call()
func run() -> void:
root.size = Vector2i(1024, 768)
var world = Python3DSurface.new()
var ui = PythonUISurface.new()
root.add_child(ui)
root.add_child(world)
ui.run_app()
await _pump_until(15, func(): return _py(
"import __main__, os, networkModule, introLogin, introSelect, game, player, background, chr, chrmgr\n"
+ "__main__._stream = [o for o in __import__('gc').get_objects() if isinstance(o, networkModule.MainStream)][0]"
))
await _pump_until(20, func(): return _py("assert isinstance(_stream.curPhaseWindow, introLogin.LoginWindow)"))
var host = OS.get_environment("MT_LIVE_HOST")
var login = OS.get_environment("MT_LIVE_LOGIN")
var pwd = OS.get_environment("MT_LIVE_PASSWORD")
var auth_port = int(OS.get_environment("MT_LIVE_AUTH_PORT"))
var game_port = int(OS.get_environment("MT_LIVE_GAME_PORT"))
_py("_stream.SetConnectInfo('%s', %d, '%s', %d)\n_stream.curPhaseWindow.Connect('%s', '%s')" % [host, game_port, host, auth_port, login, pwd])
await _pump_until(30, func(): return _py("assert isinstance(_stream.curPhaseWindow, introSelect.SelectCharacterWindow)"))
_py("_stream.curPhaseWindow.SelectSlot(0)\n_stream.curPhaseWindow.StartGame()")
await _pump_until(30, func(): return _py("assert isinstance(_stream.curPhaseWindow, game.GameWindow) and _stream.curPhaseWindow.IsShow()"))
for i in 60:
await process_frame
print("=== INSPECTING STONE TILES AROUND PLAYER (59700, 68200) ===")
var draws: Array = Metin2PythonHost.render3d_draws()
var player_z := 19851.5
for d in draws:
var tex: String = d.get("texture0", "")
if tex.contains("stone") or tex.contains("floor") or tex.contains("tile") or tex.contains("sign"):
var wm: PackedFloat32Array = d["world"]
var xform: Transform3D = Python3DSurface.d3d_transform(wm)
# check if this object is near player (within 2000cm):
if absf(xform.origin.x - 59700.0) < 3000.0 and absf(-xform.origin.y - 68200.0) < 3000.0:
var min_z := 1e9
var max_z := -1e9
for p in d["positions"]:
var wp: Vector3 = xform * p
min_z = minf(min_z, wp.z)
max_z = maxf(max_z, wp.z)
print("Object [%s] at (%.1f, %.1f, %.1f), Z range: [%.2f, %.2f], diff vs player_z(19851.5): min=%.2f, max=%.2f" % [
tex.get_file(), xform.origin.x, -xform.origin.y, xform.origin.z, min_z, max_z, min_z - player_z, max_z - player_z
])
quit(0)

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