diff --git a/.gitignore b/.gitignore index 7c533c48..82f759a2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,7 @@ -# Metin2 game assets (project/asset_root.gd). Committed to THIS self-hosted -# private repo for one-clone multi-device builds. Do not publish/redistribute -# — copyrighted Ymir art. +# Metin2 game assets are private. Do not publish or redistribute Ymir art. # build /build/ -# 渲染测试的落图输出(test/rendering 用例自建) -/project/build/ /build-ios/ /build-android/ /build-classic/ @@ -16,24 +12,6 @@ /build-native/ 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 .DS_Store @@ -76,8 +54,8 @@ __pycache__/ /test/compare-shots/ /test/compare-report.json -# Written by 40250 uitaskbar.py (SaveMouseButtonSettings) into the working directory at runtime. -/project/mouse.cfg +# Written by 40250 uitaskbar.py at runtime. +/mouse.cfg # native Android client (./build-android-native.sh): SDL3 download, NDK builds, Gradle output /build-android-native/ diff --git a/.gitmodules b/.gitmodules index 2bf5f558..935f8c39 100644 --- a/.gitmodules +++ b/.gitmodules @@ -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"] path = extension/third_party/zstd url = https://github.com/facebook/zstd.git diff --git a/CMakeLists.txt b/CMakeLists.txt index f35b1112..664c2459 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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) -project(mtgodot_poc CXX C) +project(metin2_native CXX C) include(CTest) set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_C_STANDARD 17) +set(CMAKE_EXPORT_COMPILE_COMMANDS ON) if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) set(CMAKE_BUILD_TYPE Debug CACHE STRING "" FORCE) 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(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) endif() -# --- the Godot extension --- -# Phase 1 was macOS-only; Phase 2 (BACKLOG F1/F2) adds iOS + Android. The three -# native deps are vendored (extension/third_party), so the only gate now is -# "is this a platform we've wired output naming + a toolchain for". -if(MTGODOT_BUILD_EXTENSION) - set(_mt_plat "${CMAKE_SYSTEM_NAME}") - if(_mt_plat STREQUAL "Darwin" OR _mt_plat STREQUAL "iOS" OR _mt_plat STREQUAL "Android") - message(STATUS "mtgodot extension: building for ${_mt_plat}") - else() - message(FATAL_ERROR - "MTGODOT_BUILD_EXTENSION: supported targets are macOS / iOS / Android " - "(got CMAKE_SYSTEM_NAME='${_mt_plat}'). See docs/PLATFORMS.md. " - "Use -DMTGODOT_BUILD_EXTENSION=OFF to build just libgr2/formats/tools.") - endif() - add_subdirectory(extension) -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) +# The protocol probe remains useful on the archived server's FreeBSD host. +if(MT_BUILD_NET_TOOLS) add_library(mtnet_host STATIC + extension/src/net/text_codec.cpp extension/src/net/secure_cipher.cpp extension/src/net/net_stream.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 ) 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) add_executable(net_classic_e2e_host extension/tools/net_classic_e2e.cpp) target_link_libraries(net_classic_e2e_host PRIVATE mtnet_host) endif() -# port_logic has no Godot dependency. On targets without an extension build (Linux, Windows) it is -# 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) +if(MT_BUILD_NATIVE_RENDER) add_subdirectory(native_render) endif() diff --git a/README.md b/README.md index 6008b066..a2bd0449 100644 --- a/README.md +++ b/README.md @@ -1,193 +1,36 @@ -# mtgodot-poc +# Metin2 原生客户端 -「**Godot 4 + 自研资源 loader**」跨平台方案的渲染 Demo。 -用一个 C++ GDExtension 把 Metin2 的 `.gr2` 骨骼资源渲染进 Godot,用 Godot 内置渲染器。 +40250 客户端的 macOS / Android 移植。SDL3 负责窗口与输入,Vulkan 负责 3D 和 UI 渲染;macOS 上通过 MoltenVK 使用 Metal。40250 的 C++ 逻辑、Python 2.7 脚本和 EPK 资源由 `extension/src/port` 与 `extension/src/platform` 提供。运行时不依赖 Godot。 -> **2026-08-29:并入 `xrender-poc`。** 原先与本仓库并行的 bgfx 自研引擎方案(`xrender-poc`) -> 经中期评审([`docs/MIDREVIEW.md`](docs/MIDREVIEW.md))**停止开发**,其可复用部分—— -> `libgr2`(gr2 v6/v7 读取器,已对拍 Granny)、`formats`(.msa/.msm)、`oracle`(Granny 真值工具)、 -> `tools`(libgr2 校验工具)、`docs/reference`(格式逆向笔记)——已 **vendored 进本仓库**。 -> bgfx 侧的渲染器 / 平台壳 / 第三方库全部弃用(Godot 取代)。bgfx demo 的参考截图留在 -> `test/bgfx-reference/` 供交叉核对。`xrender-poc` 目录本身已删除(2026-08-30)。 +## macOS -自用项目(内部研究,不对外发布),分两阶段推进: +需要 CMake、SDL3、Vulkan loader、MoltenVK、glslc,以及仓库中的第三方子模块: -| 阶段 | 平台 | 里程碑 | 产出 | -|---|---|---|---| -| **Phase 1**(当前) | macOS | M0' · M1 · M2 · M2.5 | 中期评审 | -| Phase 2 | Android(一加 13 / Vulkan)· iOS(iPhone 16 / Metal) | M3 | 三设备各一次 bring-up + 性能留档 | - -自用、不对外发布,**无正式 go/no-go 门禁**;三台目标设备都是现代硬件,**全程不涉及 GLES3 / Compatibility 渲染器**,不做中低端 / 老机器。 - -完整计划见 [`docs/GODOT-POC-PLAN.md`](docs/GODOT-POC-PLAN.md);离可用客户端还差什么见 -[`docs/BACKLOG.md`](docs/BACKLOG.md)(分层未完成清单);把参考图那种整张地图画面做出来的实施规格见 -[`docs/SHINSOO-WORLD-RENDERING.md`](docs/SHINSOO-WORLD-RENDERING.md)(正式移植,Phase 2 bring-up 之后); -距参考图「目视等价」还差哪些保真化工作见 [`docs/PARITY-GAP.md`](docs/PARITY-GAP.md)。 - -当前进入游戏、模型/发型、贴地与技能栏问题的分阶段修复步骤,以及结合两个 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 +```sh +git submodule update --init --recursive +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 \ + --live-client "/path/to/40250/Client" --login-screen --width 1024 --height 768 ``` -首次会编译 godot-cpp(几分钟)。扩展构建自动带上 vendored `libgr2` + `formats`。 +详见 [native_render/README.md](native_render/README.md)。 -只想构建 `libgr2` / `formats` / 校验工具(不碰 godot-cpp): +## Android -```bash -cmake -B build-tools -DMTGODOT_BUILD_EXTENSION=OFF -DMTGODOT_BUILD_TOOLS=ON -cmake --build build-tools -j8 -./build-tools/tools/gr2fuzz "$PWD/assets" # 全量解析冒烟 -./build-tools/tools/gr2dump +```sh +./build-android-native.sh Release --install +./push-android-client.sh /path/to/40250/Client +adb shell am start -n org.metin2port.client/.MainActivity ``` -## 运行 +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 导出模板) -./build-macos-client.sh [debug|release] # -> build/export/mtgodot-poc.app - -# 编辑器打开 -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 +```sh +./script/port_gate.sh macos ``` -控制台出现 `[mtgodot] Metin2Model registered OK — libgr2 linked; ...` = M0' 的 T0.1/T0.2 通过。 - -窗口内:拖拽 = 轨道,滚轮 = 缩放,`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 重写)。 -本仓库内部研究用途,不对外公开。 +`port_gate.sh` 构建并运行 40250 移植层的 C++ 测试。原生渲染验收入口是 `script/native_mac_acceptance.mjs`。历史迁移记录保留在 `docs/PORT-PLAN.md`;其中旧 Godot 路径已经退役。 diff --git a/android-native/app/src/main/java/org/metin2port/client/MainActivity.java b/android-native/app/src/main/java/org/metin2port/client/MainActivity.java index 764f3497..059071de 100644 --- a/android-native/app/src/main/java/org/metin2port/client/MainActivity.java +++ b/android-native/app/src/main/java/org/metin2port/client/MainActivity.java @@ -1,6 +1,13 @@ package org.metin2port.client; 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; @@ -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/. */ 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 protected String[] getLibraries() { return new String[] { "SDL3", "main" }; @@ -20,8 +68,15 @@ public class MainActivity extends SDLActivity { protected String[] getArguments() { Intent intent = getIntent(); String args = intent != null ? intent.getStringExtra("args") : null; + Log.i("Metin2Args", "native args: " + args); if (args == null || args.trim().isEmpty()) return new String[0]; 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); + } } diff --git a/build-android-native.sh b/build-android-native.sh index ba2d5e85..1938540d 100755 --- a/build-android-native.sh +++ b/build-android-native.sh @@ -57,8 +57,8 @@ fi # --- libmain.so (native_render + port_platform + embedded Python) and its assets --- cmake -S . -B "$OUT/app-build" -G "Unix Makefiles" -Wno-deprecated \ -DCMAKE_TOOLCHAIN_FILE="$TOOLCHAIN" -DANDROID_ABI=arm64-v8a -DANDROID_PLATFORM="android-$API" \ - -DCMAKE_BUILD_TYPE="$CONFIG" -DMTGODOT_BUILD_TOOLS=OFF -DBUILD_TESTING=OFF \ - -DMT_BUILD_NATIVE_RENDER_PROTOTYPE=ON -DSDL3_DIR="$OUT/sdl3/lib/cmake/SDL3" \ + -DCMAKE_BUILD_TYPE="$CONFIG" -DMT_BUILD_TOOLS=OFF -DBUILD_TESTING=OFF \ + -DMT_BUILD_NATIVE_RENDER=ON -DSDL3_DIR="$OUT/sdl3/lib/cmake/SDL3" \ -DCMAKE_FIND_ROOT_PATH="$OUT/sdl3" -DGLSLC="$GLSLC" >/dev/null cmake --build "$OUT/app-build" --target main mt_native_android_assets -j "$JOBS" diff --git a/build-android.sh b/build-android.sh deleted file mode 100755 index 2d970028..00000000 --- a/build-android.sh +++ /dev/null @@ -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/). -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 diff --git a/build-ios.sh b/build-ios.sh deleted file mode 100755 index d5546f8b..00000000 --- a/build-ios.sh +++ /dev/null @@ -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" diff --git a/build-macos-client.sh b/build-macos-client.sh deleted file mode 100755 index 998dde10..00000000 --- a/build-macos-client.sh +++ /dev/null @@ -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'" diff --git a/build.sh b/build.sh deleted file mode 100755 index 9321f3c7..00000000 --- a/build.sh +++ /dev/null @@ -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)" diff --git a/docs/ANDROID-TESTING.md b/docs/ANDROID-TESTING.md index a79c92b2..8bb553c6 100644 --- a/docs/ANDROID-TESTING.md +++ b/docs/ANDROID-TESTING.md @@ -1,393 +1,24 @@ - -# 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`。 - -当前仓库的资源部署存在一个需要特别注意的限制: - -- `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" +```sh +./build-android-native.sh Release --install +./push-android-client.sh /path/to/40250/Client +adb shell am start -n org.metin2port.client/.MainActivity ``` -先确认工具存在: +`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 -"$ADB" version -test -f "$ANDROID_NDK_ROOT/build/cmake/android.toolchain.cmake" +## 连服务器 + +```sh +adb shell am start -n org.metin2port.client/.MainActivity \ + --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 的“编辑器设置 → 导出 → 导出模板”中安装。 - -首次导出前生成 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\t<服务器IP>\t\t1,2,3,4\t1\t0 -``` - -例如 40250 仓库脚本中的默认 channel1 端口与当前客户端内置默认值并不相同,必须以目标 FreeBSD 服务器的 `CONFIG` 和实际监听结果为准。 - - - -### 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 后端后,才进入真实服务器功能验收。 +更详细的构建依赖与命令见 [native_render/README.md](../native_render/README.md)。 diff --git a/docs/PLATFORMS.md b/docs/PLATFORMS.md index d2d8c140..d95cf880 100644 --- a/docs/PLATFORMS.md +++ b/docs/PLATFORMS.md @@ -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 -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`. +当前客户端使用 SDL3 + Vulkan 原生渲染,目标平台是 macOS arm64 和 Android arm64。核心 40250 移植代码在 `extension/src/port` 与 `extension/src/platform`,与 UI 引擎无关。 -**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 -build is kept only as a portability gate (it catches real include/layering bugs), -not as Windows support, and MSVC is not built at all. +| 平台 | 构建入口 | 产物 | +| --- | --- | --- | +| 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` | +| Android | `./build-android-native.sh Release` | `android-native/app/build/outputs/apk/debug/app-debug.apk` | -## 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 | -|---------------------|---------------------------|---------------------------------------------------------| -| `./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\t`, paths relative to assets_root), and - `build_or_load(root)` → loads `/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 - `/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. +原生客户端的详细配置与验收步骤见 [native_render/README.md](../native_render/README.md) 和 [ANDROID-TESTING.md](ANDROID-TESTING.md)。 diff --git a/docs/PORT-PLAN.md b/docs/PORT-PLAN.md index 801fe137..7e087c69 100644 --- a/docs/PORT-PLAN.md +++ b/docs/PORT-PLAN.md @@ -1,5 +1,7 @@ # 40250 1:1 移植:路线与方案 +> 历史迁移记录:2026-09-28 起,客户端使用 `native_render/` 的 SDL3 + Vulkan 原生路径。本文关于 Godot 工程、GDExtension、`project/`、Godot 导出和相应门禁的描述仅记录过去的迁移过程;这些代码已删除。当前构建方法见根目录 README 和 `native_render/README.md`。 + 本文是移植工作的总入口,换一台电脑继续开发时先读这份。更新日期:2026-09-22。 - 方法细则:`.agents/skills/metin2-40250-parity-audit/SKILL.md`(每轮的做法、状态定义、工具) diff --git a/export-android.sh b/export-android.sh deleted file mode 100755 index bb0b34da..00000000 --- a/export-android.sh +++ /dev/null @@ -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/ -# (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 diff --git a/extension/CMakeLists.txt b/extension/CMakeLists.txt deleted file mode 100644 index c125b04f..00000000 --- a/extension/CMakeLists.txt +++ /dev/null @@ -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 $) - - 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 $) - - 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 $) - - 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 $) - - 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 $) - - # 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 $) - - 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 $) - - 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 $) - - 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 $) - - 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 $) - - 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 $) - - 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 $) - - add_executable(net_bounds_test tests/net_bounds_test.cpp) - target_link_libraries(net_bounds_test PRIVATE mtnet) - add_test(NAME net.bounds COMMAND $) - - 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 $) - - add_executable(pack_roundtrip_test tests/pack_roundtrip_test.cpp) - target_link_libraries(pack_roundtrip_test PRIVATE mtpack) - add_test(NAME pack.roundtrip COMMAND $) - - 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 $ ${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 $) - - # 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 $ "${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}..so -set(MT_CFG_TAG "$,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" -) diff --git a/extension/godot-cpp b/extension/godot-cpp deleted file mode 160000 index 101ae380..00000000 --- a/extension/godot-cpp +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 101ae38034304346a46ea9ea84ae156d3e860496 diff --git a/extension/src/asset_io.cpp b/extension/src/asset_io.cpp deleted file mode 100644 index 5d831f35..00000000 --- a/extension/src/asset_io.cpp +++ /dev/null @@ -1,72 +0,0 @@ -#include "asset_io.h" - -#include - -#include "platform/PackBackend.h" - -#include -#include -#include - -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 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_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 diff --git a/extension/src/asset_io.h b/extension/src/asset_io.h deleted file mode 100644 index 2d2c3561..00000000 --- a/extension/src/asset_io.h +++ /dev/null @@ -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() has run -// (platform/PackBackend.h). The path after pack:// is passed as UTF-8. -#pragma once - -#include -#include - -#include - -#include - -#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_from_file(const godot::String &path, gr2::LoadError *err = nullptr); - -} // namespace mtgodot diff --git a/extension/src/dxt.cpp b/extension/src/dxt.cpp index 96030c5e..1849a4a9 100644 --- a/extension/src/dxt.cpp +++ b/extension/src/dxt.cpp @@ -4,7 +4,7 @@ #include #include -namespace mtgodot { +namespace mtimage { namespace { 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()); } -} // namespace mtgodot +} // namespace mtimage diff --git a/extension/src/dxt.h b/extension/src/dxt.h index 6fe814d3..98931b7e 100644 --- a/extension/src/dxt.h +++ b/extension/src/dxt.h @@ -1,5 +1,5 @@ // 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 // 自己写 ~150 行绕开 reuse/EterImageLib 的 windows.h 依赖(M1 风险表允许)。 // 对拍时两侧都喂软解 RGBA,不被 GPU S3TC 的 bit 级差异污染。 @@ -7,7 +7,7 @@ #include #include -namespace mtgodot { +namespace mtimage { struct Image { 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_path(const char* path); -} // namespace mtgodot +} // namespace mtimage diff --git a/extension/src/environment_builder.cpp b/extension/src/environment_builder.cpp deleted file mode 100644 index 1463285e..00000000 --- a/extension/src/environment_builder.cpp +++ /dev/null @@ -1,451 +0,0 @@ -#include "environment_builder.h" - -#include "asset_io.h" -#include "dxt.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include - -#include - -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 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 image = godot::Image::create_from_data( - 1, 1, false, godot::Image::FORMAT_RGBA8, pixels); - image->generate_mipmaps(); - return ImageTexture::create_from_image(image); -} - -Ref load_texture(const std::string &name, const fmt::AssetResolver *resolver) { - if (name.empty() || resolver == nullptr) - return Ref(); - const std::string path = resolver->resolve(name, nullptr); - if (path.empty()) - return Ref(); - Ref 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(); - 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(); - 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(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( - 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(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 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 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.instantiate(); - std::array, 6> faces; - std::array 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 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.instantiate(); - shader->set_code(String(SRC_SKYBOX)); - Ref 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 diff --git a/extension/src/environment_builder.h b/extension/src/environment_builder.h deleted file mode 100644 index c6aee57b..00000000 --- a/extension/src/environment_builder.h +++ /dev/null @@ -1,26 +0,0 @@ -#pragma once - -#include -#include - -#include -#include - -// 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 diff --git a/extension/src/gr2_bridge.cpp b/extension/src/gr2_bridge.cpp deleted file mode 100644 index 8937ff05..00000000 --- a/extension/src/gr2_bridge.cpp +++ /dev/null @@ -1,244 +0,0 @@ -#include "gr2_bridge.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include - -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 build_skin(const gr2::Skeleton &sk) { - Ref 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 build_parts(const gr2::FileInfo &fi) { - std::vector 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 build_mesh(const gr2::FileInfo &fi, const std::vector &parts, - bool flip_winding, AABB &out_bounds) { - Ref 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 diff --git a/extension/src/gr2_bridge.h b/extension/src/gr2_bridge.h deleted file mode 100644 index b4bd6e29..00000000 --- a/extension/src/gr2_bridge.h +++ /dev/null @@ -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 -#include -#include - -#include - -#include -#include - -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 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 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 build_mesh(const gr2::FileInfo &fi, - const std::vector &parts, bool flip_winding, - godot::AABB &out_bounds); - -} // namespace mtgodot diff --git a/extension/src/m2_material.cpp b/extension/src/m2_material.cpp deleted file mode 100644 index 2c31b227..00000000 --- a/extension/src/m2_material.cpp +++ /dev/null @@ -1,276 +0,0 @@ -#include "m2_material.h" - -#include -#include -#include -#include -#include - -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 s_mix; -Ref s_add; -Ref s_skin; -Ref s_add_skin; -// cull_disabled variants for two-sided parts (hair / cloth / foliage; client -// ExtendedData "Two-sided" -> D3DRS_CULLMODE = D3DCULL_NONE). -Ref s_mix_2s; -Ref s_add_2s; -Ref s_skin_2s; -Ref s_add_skin_2s; -Ref s_opaque[4]; - -Ref shader_for(bool additive, bool skinned, bool two_sided, bool opaque) { - if (opaque) { - Ref &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 &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 make_material(const MaterialDesc &d) { - Ref 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 diff --git a/extension/src/m2_material.h b/extension/src/m2_material.h deleted file mode 100644 index 15725749..00000000 --- a/extension/src/m2_material.h +++ /dev/null @@ -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 - -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 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 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 spec_map; // shared sphere map; null -> disabled - float spec_power = 0.0f; // fSpecularPower (D3DRS_TEXTUREFACTOR.a); 0 = off -}; - -godot::Ref 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 diff --git a/extension/src/metin2_anim.cpp b/extension/src/metin2_anim.cpp deleted file mode 100644 index f5043df8..00000000 --- a/extension/src/metin2_anim.cpp +++ /dev/null @@ -1,856 +0,0 @@ -#include "metin2_anim.h" - -#include "asset_io.h" -#include "gr2_bridge.h" -#include "metin2_model.h" - -#include -#include -#include - -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include - -#include - -using namespace godot; - -namespace mtgodot { - -namespace { -struct ClipEntry { - String path; - uint64_t modified; - size_t bytes; - std::shared_ptr 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 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 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 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(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(clip_cache.size()); - d["section_bytes"] = static_cast(clip_bytes); - d["msa_hits"] = msa_hits; - d["msa_misses"] = msa_misses; - d["msa_entries"] = static_cast(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(n); -} - -// "d:\Ymir Work\pc\warrior\action\dance.gr2" -> "/PC/ymir work/pc/warrior/action/dance.gr2" -// where is found by walking up from the .msa's own dir past "ymir work". -// Falls back to / (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. /PC/, 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 &local) { - std::vector 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 &local, - const gr2::Mat4 &root_offset, std::vector &world, - std::vector &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(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 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 diff --git a/extension/src/metin2_anim.h b/extension/src/metin2_anim.h deleted file mode 100644 index 0061c0be..00000000 --- a/extension/src/metin2_anim.h +++ /dev/null @@ -1,140 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include - -#include -#include - -#include -#include -#include - -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 anim_file; - std::vector world_buf; - std::vector 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 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 local_buf, prev_local_buf, blend_local_buf; - - godot::Vector3 accumulation; // .msa Accumulation (root motion) - std::vector 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 diff --git a/extension/src/metin2_model.cpp b/extension/src/metin2_model.cpp deleted file mode 100644 index 4bf22a68..00000000 --- a/extension/src/metin2_model.cpp +++ /dev/null @@ -1,1770 +0,0 @@ -#include "metin2_model.h" - -#include "asset_io.h" -#include "dxt.h" -#include "gr2_bridge.h" -#include "m2_material.h" -#include "texture_util.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include - -#include -#include - -using namespace godot; - -namespace mtgodot { - -static mtgodot::BlendMode guess_blend(const godot::String &surface_name); // defined below -static const gr2::MaterialInfo *match_material(const std::vector &mats, - const godot::String &want_base); -static mtgodot::BlendMode decide_blend(const gr2::MaterialInfo *m, const godot::String &sname); -static bool decide_two_sided(const gr2::MaterialInfo *m, const godot::String &sname); - -static HashMap> s_model_tex_cache; -static HashMap s_model_dir_dds_cache; - -static bool model_diagnostics() { - static int diag = -1; - if (diag == -1) { - diag = (OS::get_singleton()->get_environment("MTGODOT_MODEL_DIAGNOSTICS") == "1") ? 1 : 0; - } - return diag == 1; -} - -void Metin2Model::clear_texture_cache() { - s_model_tex_cache.clear(); - s_model_dir_dds_cache.clear(); -} - -Metin2Model::Metin2Model() { - file = std::make_shared>(); - lod_dist.push_back(18.0f); - lod_dist.push_back(42.0f); - lod_dist.push_back(90.0f); -} -Metin2Model::~Metin2Model() = default; - -void Metin2Model::_bind_methods() { - ClassDB::bind_method(D_METHOD("set_gr2_path", "path"), &Metin2Model::set_gr2_path); - ClassDB::bind_method(D_METHOD("get_gr2_path"), &Metin2Model::get_gr2_path); - ClassDB::bind_method(D_METHOD("set_texture_dir", "path"), &Metin2Model::set_texture_dir); - ClassDB::bind_method(D_METHOD("get_texture_dir"), &Metin2Model::get_texture_dir); - ClassDB::bind_method(D_METHOD("set_unit_scale", "s"), &Metin2Model::set_unit_scale); - ClassDB::bind_method(D_METHOD("get_unit_scale"), &Metin2Model::get_unit_scale); - ClassDB::bind_method(D_METHOD("set_flip_z", "v"), &Metin2Model::set_flip_z); - ClassDB::bind_method(D_METHOD("get_flip_z"), &Metin2Model::get_flip_z); - ClassDB::bind_method(D_METHOD("set_flip_winding", "v"), &Metin2Model::set_flip_winding); - ClassDB::bind_method(D_METHOD("get_flip_winding"), &Metin2Model::get_flip_winding); - ClassDB::bind_method(D_METHOD("set_material_mode", "m"), &Metin2Model::set_material_mode); - ClassDB::bind_method(D_METHOD("get_material_mode"), &Metin2Model::get_material_mode); - ClassDB::bind_method(D_METHOD("set_use_gr2_materials", "v"), &Metin2Model::set_use_gr2_materials); - ClassDB::bind_method(D_METHOD("get_use_gr2_materials"), &Metin2Model::get_use_gr2_materials); - ClassDB::bind_method(D_METHOD("set_specular_power", "p"), &Metin2Model::set_specular_power); - ClassDB::bind_method(D_METHOD("get_specular_power"), &Metin2Model::get_specular_power); - ClassDB::bind_method(D_METHOD("set_surface_texture", "surface", "path"), &Metin2Model::set_surface_texture); - ClassDB::bind_method(D_METHOD("set_skin_texture", "source", "target"), &Metin2Model::set_skin_texture); - ClassDB::bind_method(D_METHOD("clear_skin_textures"), &Metin2Model::clear_skin_textures); - ClassDB::bind_method(D_METHOD("reload"), &Metin2Model::reload); - ClassDB::bind_method(D_METHOD("get_info"), &Metin2Model::get_info); - ClassDB::bind_method(D_METHOD("get_visual_aabb"), &Metin2Model::get_visual_aabb); - ClassDB::bind_method(D_METHOD("get_fly_target_bounds"), &Metin2Model::get_fly_target_bounds); - ClassDB::bind_method(D_METHOD("get_ground_offset"), &Metin2Model::get_ground_offset); - ClassDB::bind_method(D_METHOD("get_hair_options"), &Metin2Model::get_hair_options); - ClassDB::bind_method(D_METHOD("set_hair_gr2", "p"), &Metin2Model::set_hair_gr2); - ClassDB::bind_method(D_METHOD("get_hair_gr2"), &Metin2Model::get_hair_gr2); - ClassDB::bind_method(D_METHOD("set_hair_skin", "p"), &Metin2Model::set_hair_skin); - ClassDB::bind_method(D_METHOD("get_hair_skin"), &Metin2Model::get_hair_skin); - ClassDB::bind_method(D_METHOD("set_weapon_gr2", "p"), &Metin2Model::set_weapon_gr2); - ClassDB::bind_method(D_METHOD("get_weapon_gr2"), &Metin2Model::get_weapon_gr2); - ClassDB::bind_method(D_METHOD("set_weapon_bone", "b"), &Metin2Model::set_weapon_bone); - ClassDB::bind_method(D_METHOD("get_weapon_bone"), &Metin2Model::get_weapon_bone); - ClassDB::bind_method(D_METHOD("set_shield_gr2", "p"), &Metin2Model::set_shield_gr2); - ClassDB::bind_method(D_METHOD("get_shield_gr2"), &Metin2Model::get_shield_gr2); - ClassDB::bind_method(D_METHOD("set_shield_bone", "b"), &Metin2Model::set_shield_bone); - ClassDB::bind_method(D_METHOD("get_shield_bone"), &Metin2Model::get_shield_bone); - ClassDB::bind_method(D_METHOD("probe_gr2", "path"), &Metin2Model::probe_gr2); - ClassDB::bind_static_method("Metin2Model", D_METHOD("clear_texture_cache"), &Metin2Model::clear_texture_cache); - // A renderer consumer may have framed the model before an animation switches - // it from the CPU mesh to GPU skinning. That switch updates the effective - // AABB, so expose a small notification instead of making each scene guess a - // number of frames to wait. - ADD_SIGNAL(MethodInfo("visual_bounds_changed")); - - ADD_PROPERTY(PropertyInfo(Variant::STRING, "gr2_path", PROPERTY_HINT_GLOBAL_FILE, "*.gr2,*.msm"), - "set_gr2_path", "get_gr2_path"); - ADD_PROPERTY(PropertyInfo(Variant::STRING, "texture_dir", PROPERTY_HINT_GLOBAL_DIR), - "set_texture_dir", "get_texture_dir"); - ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "unit_scale", PROPERTY_HINT_RANGE, "0.0001,1,0.0001"), - "set_unit_scale", "get_unit_scale"); - ADD_PROPERTY(PropertyInfo(Variant::BOOL, "flip_z"), "set_flip_z", "get_flip_z"); - ADD_PROPERTY(PropertyInfo(Variant::BOOL, "flip_winding"), "set_flip_winding", "get_flip_winding"); - ADD_PROPERTY(PropertyInfo(Variant::STRING, "material_mode", PROPERTY_HINT_ENUM, "metin2,standard"), - "set_material_mode", "get_material_mode"); - ADD_PROPERTY(PropertyInfo(Variant::BOOL, "use_gr2_materials"), - "set_use_gr2_materials", "get_use_gr2_materials"); - ClassDB::bind_method(D_METHOD("set_lod_enabled", "v"), &Metin2Model::set_lod_enabled); - ClassDB::bind_method(D_METHOD("get_lod_enabled"), &Metin2Model::get_lod_enabled); - ClassDB::bind_method(D_METHOD("set_lod_distances", "d"), &Metin2Model::set_lod_distances); - ClassDB::bind_method(D_METHOD("get_lod_distances"), &Metin2Model::get_lod_distances); - ClassDB::bind_method(D_METHOD("get_lod_level"), &Metin2Model::get_lod_level); - ADD_PROPERTY(PropertyInfo(Variant::BOOL, "lod_enabled"), "set_lod_enabled", "get_lod_enabled"); - ADD_PROPERTY(PropertyInfo(Variant::PACKED_FLOAT32_ARRAY, "lod_distances"), - "set_lod_distances", "get_lod_distances"); - ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "specular_power", PROPERTY_HINT_RANGE, "0,4,0.01"), - "set_specular_power", "get_specular_power"); - ADD_PROPERTY(PropertyInfo(Variant::STRING, "hair_gr2", PROPERTY_HINT_GLOBAL_FILE, "*.gr2"), - "set_hair_gr2", "get_hair_gr2"); - ADD_PROPERTY(PropertyInfo(Variant::STRING, "hair_skin", PROPERTY_HINT_GLOBAL_FILE, "*.dds"), - "set_hair_skin", "get_hair_skin"); - ADD_PROPERTY(PropertyInfo(Variant::STRING, "weapon_gr2", PROPERTY_HINT_GLOBAL_FILE, "*.gr2"), - "set_weapon_gr2", "get_weapon_gr2"); - ADD_PROPERTY(PropertyInfo(Variant::STRING, "weapon_bone"), "set_weapon_bone", "get_weapon_bone"); - ADD_PROPERTY(PropertyInfo(Variant::STRING, "shield_gr2", PROPERTY_HINT_GLOBAL_FILE, "*.gr2"), - "set_shield_gr2", "get_shield_gr2"); - ADD_PROPERTY(PropertyInfo(Variant::STRING, "shield_bone"), "set_shield_bone", "get_shield_bone"); -} - -void Metin2Model::set_material_mode(const String &m) { - material_mode = m; - if (is_inside_tree()) { - reload(); - } -} - -void Metin2Model::set_specular_power(double p) { - specular_power = p < 0.0 ? 0.0 : p; - if (is_inside_tree() && mi) { - _apply_materials(); - } -} - -void Metin2Model::set_use_gr2_materials(bool v) { - use_gr2_materials = v; - if (is_inside_tree()) { - _apply_materials(); - } -} - -void Metin2Model::set_surface_texture(int surface, const String &path) { - if (surface < 0) { - return; - } - while (surface_tex_override.size() <= surface) { - surface_tex_override.push_back(String()); - } - surface_tex_override[surface] = path; - if (is_inside_tree()) { - _apply_materials(); - } -} - -void Metin2Model::set_skin_texture(const String &source, const String &target) { - const String key = source.replace("\\", "/").get_file().get_basename().to_lower(); - if (key.is_empty()) { - return; - } - if (target.is_empty()) { - skin_tex_override.erase(key); - } else { - skin_tex_override[key] = target; - } - if (is_inside_tree() && mi) { - _apply_materials(); - } -} - -void Metin2Model::clear_skin_textures() { - skin_tex_override.clear(); - if (is_inside_tree() && mi) { - _apply_materials(); - } -} - -void Metin2Model::set_gr2_path(const String &p) { - gr2_path = p; - if (is_inside_tree()) { - reload(); - } -} -void Metin2Model::set_texture_dir(const String &p) { - texture_dir = p; - if (is_inside_tree()) { - reload(); - } -} -void Metin2Model::set_unit_scale(double s) { - unit_scale = s; - if (is_inside_tree()) { - reload(); - } -} -void Metin2Model::set_flip_z(bool v) { - flip_z = v; - if (is_inside_tree()) { - reload(); - } -} -void Metin2Model::set_flip_winding(bool v) { - flip_winding = v; - if (is_inside_tree()) { - reload(); - } -} - -void Metin2Model::_ready() { - set_process(true); - if (!gr2_path.is_empty()) { - reload(); - } -} - -void Metin2Model::set_lod_enabled(bool v) { - lod_enabled = v; - if (!v && lod_level != 0) { - _set_lod(0); - } -} - -void Metin2Model::set_lod_distances(const PackedFloat32Array &d) { - lod_dist = d; -} - -const gr2::FileInfo *Metin2Model::active_fi() const { - if (lod_level >= 1 && lod_level <= (int)lod_files.size() && lod_files[lod_level - 1] && - *lod_files[lod_level - 1]) { - return &(**lod_files[lod_level - 1]).file_info(); - } - return (file && *file) ? &(*file)->file_info() : nullptr; -} - -void Metin2Model::_load_lods(const String &loaded_spec) { - lod_files.clear(); - lod_parts.clear(); - String stem = loaded_spec.get_basename(); // ".../warrior_cheongrin" - for (int i = 1; i <= 3; ++i) { - String lp = stem + vformat("_lod_%02d.gr2", i); - if (!FileAccess::file_exists(lp)) { - break; - } - gr2::LoadError e; - auto f = mtgodot::gr2_from_file(lp, &e); - if (!f) { - UtilityFunctions::push_warning(String("[Metin2Model] lod load failed: ") + lp); - break; - } - auto slot = std::make_shared>(std::move(f)); - lod_parts.push_back(mtgodot::build_parts((**slot).file_info())); - lod_files.push_back(std::move(slot)); - } - if (!lod_files.empty()) { - if (model_diagnostics()) { - UtilityFunctions::print(vformat("[Metin2Model] LOD: %d level(s) loaded", (int)lod_files.size())); - } - } -} - -namespace { -constexpr double kLodFadeDur = 0.18; -void set_lod_fade(const std::vector> &mats, float v) { - for (const Ref &m : mats) { - Ref sm = m; - if (sm.is_valid()) { - sm->set_shader_parameter("lod_fade", v); - } - } -} -} // namespace - -void Metin2Model::_end_lod_fade() { - if (lod_prev_mi) { - remove_child(lod_prev_mi); - lod_prev_mi->queue_free(); - lod_prev_mi = nullptr; - } - lod_fade_t = -1.0; - set_lod_fade(surf_mats, 1.0f); -} - -void Metin2Model::_set_lod(int n) { - n = n < 0 ? 0 : (n > (int)lod_files.size() ? (int)lod_files.size() : n); - if (n == lod_level || !mi) { - return; - } - - // snapshot the outgoing level into a frozen ghost that fades out - _end_lod_fade(); - if (mi->get_mesh().is_valid() && material_mode != String("standard")) { - lod_prev_mi = memnew(MeshInstance3D); - lod_prev_mi->set_name("LodGhost"); - lod_prev_mi->set_layer_mask(1u << 1); - lod_prev_mi->set_mesh(mi->get_mesh()); - lod_prev_mi->set_custom_aabb(mi->get_custom_aabb()); - add_child(lod_prev_mi); - lod_prev_mi->set_transform(mi->get_transform()); - std::vector> ghost_mats; - // A frozen GPU ghost must not share the live, updated bone texture. - Ref ghost_bones; - if (gpu_skin_active && bones_img.is_valid()) { - Ref snapshot = bones_img->duplicate(); - ghost_bones = ImageTexture::create_from_image(snapshot); - } - for (int s = 0; s < mi->get_mesh()->get_surface_count(); ++s) { - // CPU hair overrides are not necessarily present in surf_mats. - Ref src = mi->get_active_material(s); - Ref dup; - if (src.is_valid()) { - dup = src->duplicate(); - Ref shader = dup; - if (shader.is_valid() && ghost_bones.is_valid()) { - shader->set_shader_parameter("bones_tex", ghost_bones); - } - } - lod_prev_mi->set_surface_override_material(s, dup); - ghost_mats.push_back(dup); - } - set_lod_fade(ghost_mats, 1.0f); - lod_ghost_mats = std::move(ghost_mats); - lod_fade_t = 0.0; - } - - lod_level = n; - parts = (n == 0) ? base_parts : lod_parts[n - 1]; - const gr2::FileInfo *afi = active_fi(); - if (!afi) { - _end_lod_fade(); - return; - } - if (gpu_skin_active) { - // Keep attachments and the body/hair material boundary across LODs. - _build_gpu_mesh(); - gpu_visual_bounds_ready = false; - } else if (cpu_mesh.is_valid()) { - AABB b; - cpu_mesh = build_mesh(*afi, parts, flip_winding, b); - mi->set_mesh(cpu_mesh); - } else { - AABB b; - Ref m = build_mesh(*afi, parts, flip_winding, b); - if (m->get_surface_count() > 0) { - mi->set_mesh(m); - } - } - _apply_materials(); - if (gpu_skin_active && !current_skin.empty()) { - gpu_skin(current_skin); - } - if (cpu_mesh.is_valid() && !current_skin.empty()) { - cpu_skin(current_skin); - } - if (lod_fade_t >= 0.0) { - set_lod_fade(surf_mats, 0.0f); // new level starts invisible, fades in - } -} - -void Metin2Model::_process(double delta) { - // LOD crossfade progression - if (lod_fade_t >= 0.0) { - lod_fade_t += delta; - float k = (float)std::min(1.0, lod_fade_t / kLodFadeDur); - set_lod_fade(surf_mats, k); - set_lod_fade(lod_ghost_mats, 1.0f - k); - if (k >= 1.0f) { - _end_lod_fade(); - lod_ghost_mats.clear(); - } - } - - if (!lod_enabled || lod_files.empty() || !mi || lod_dist.is_empty()) { - return; - } - Viewport *vp = get_viewport(); - Camera3D *cam = vp ? vp->get_camera_3d() : nullptr; - if (!cam) { - return; - } - float d = get_global_position().distance_to(cam->get_global_position()); - // hysteresis: step up at lod_dist[i], step down only 15% closer - int lv = 0; - for (int i = 0; i < (int)lod_dist.size() && i < 3; ++i) { - float up = lod_dist[i]; - float down = up * 0.85f; - if (d > up) { - lv = i + 1; - } else if (d > down && lv == i && lod_level >= i + 1) { - lv = i + 1; // stay in the higher LOD across the dead-band - } - } - _set_lod(lv); -} - -void Metin2Model::_clear_children() { - skel = nullptr; - mi = nullptr; - weapon_mi = nullptr; - weapon_bone_idx = -1; - shield_mi = nullptr; - shield_bone_idx = -1; - TypedArray kids = get_children(); - for (int i = 0; i < kids.size(); ++i) { - Node *n = Object::cast_to(kids[i]); - if (n) { - remove_child(n); - n->queue_free(); - } - } -} - -void Metin2Model::reload() { - current_world_pose.clear(); - fly_target_bounds_valid = false; - _clear_children(); - *file = std::nullopt; - last_info = ""; - resolved_gr2_dir = ""; - hair_options.clear(); - if (hair_file) - *hair_file = std::nullopt; - hair_parts.clear(); - hair_bone_remap.clear(); - hair_mat.unref(); - hair_tex.unref(); - gpu_base_surf = 0; - if (weapon_file) - *weapon_file = std::nullopt; - if (shield_file) - *shield_file = std::nullopt; - materials.clear(); - parts.clear(); - base_parts.clear(); - lod_files.clear(); - lod_parts.clear(); - lod_level = 0; - surf_mats.clear(); - cpu_mesh.unref(); - gpu_skin_active = false; - gpu_visual_bounds_ready = false; - have_visual_bounds = false; - bones_img.unref(); - bones_tex.unref(); - current_skin.clear(); - - if (gr2_path.is_empty()) { - return; - } - - // .msm -> resolve BaseModelFileName; stash the hair catalog for get_hair_options(). - String gr2_spec = gr2_path; - if (gr2_path.to_lower().ends_with(".msm")) { - fmt::Msm ms; - std::string e; - if (fmt::parse_msm_file(std::string(gr2_path.utf8().get_data()), ms, &e)) { - gr2_spec = resolve_rel_gr2(gr2_path, String(ms.base_model_gr2.c_str())); - const String hp = String(ms.hair_path.c_str()); - for (const fmt::HairEntry &h : ms.hairs) { - Dictionary d; - d["index"] = h.index; - d["model"] = resolve_rel_gr2(gr2_path, hp + String(h.model.c_str())); - // SourceSkin / TargetSkin resolved to absolute (§2.4). TargetSkin is - // relative to PathName; SourceSkin too (the gr2's own texture). - const String ss(h.source_skin.c_str()), ts(h.target_skin.c_str()); - d["source_skin"] = ss.is_empty() ? String() : resolve_rel_gr2(gr2_path, hp + ss); - d["target_skin"] = ts.is_empty() ? String() : resolve_rel_gr2(gr2_path, hp + ts); - hair_options.push_back(d); - } - UtilityFunctions::print(vformat("[Metin2Model] .msm -> %s hairs=%d", - gr2_spec, (int)hair_options.size())); - } else { - UtilityFunctions::push_error(String("[Metin2Model] .msm: ") + String(e.c_str())); - return; - } - } - - gr2::LoadError err; - auto loaded = mtgodot::gr2_from_file(gr2_spec, &err); - if (!loaded) { - UtilityFunctions::push_error(String("[Metin2Model] gr2 load failed [") + - String(err.stage.c_str()) + "]: " + String(err.message.c_str()) + " (" + gr2_spec + ")"); - return; - } - *file = std::move(loaded); - resolved_gr2_dir = gr2_spec.get_base_dir(); - const gr2::FileInfo &fi = (*file)->file_info(); - - set_transform(make_conv((float)unit_scale, flip_z)); - - // Skeleton (optional). - const gr2::Skeleton *sk = fi.skeletons.empty() ? nullptr : &fi.skeletons[0]; - if (sk) { - std::vector bind_world; - gr2::bind_pose(*sk, bind_world, current_skin); - skel = build_skeleton(*sk); - if (skel) { - add_child(skel); - skel->set_owner(get_owner() ? get_owner() : this); - } - } - - // Mesh — one surface per render part (gr2 mesh split by tri_group.material_index). - parts = mtgodot::build_parts(fi); - base_parts = parts; - AABB bounds; - Ref mesh = build_mesh(fi, parts, flip_winding, bounds); - // build_mesh keeps the source GR2 coordinate frame (Z-up, centimetres); - // make_conv() below maps it to Godot Y-up and applies unit_scale. Keep the - // actor root on the terrain while translating only the visual subtree. - ground_offset = -static_cast(bounds.position.z) * unit_scale; - if (mesh->get_surface_count() > 0) { - mi = memnew(MeshInstance3D); - mi->set_name("MeshInstance3D"); - mi->set_layer_mask(1u << 1); // CharacterLight visibility layer. - mi->set_mesh(mesh); - add_child(mi); - mi->set_owner(get_owner() ? get_owner() : this); - if (skel && sk) { - mi->set_skeleton_path(mi->get_path_to(skel)); - if (OS::get_singleton()->get_environment("MTGODOT_AUTOSKIN") == "1") { - // let Godot derive the skin from skeleton rest poses - } else { - mi->set_skin(build_skin(*sk)); - } - } - mi->set_custom_aabb(bounds.grow(0.01)); - visual_bounds = bounds; - have_visual_bounds = true; - } - - materials = gr2::dump_materials(**file); - - _apply_materials(); - - int vtot = 0; - for (const auto &m : fi.meshes) { - vtot += (int)m.vertices.size(); - } - if (model_diagnostics()) { - int with_tex = 0; - for (const auto &mm : materials) { - if (!mm.diffuse_texture.empty()) { - ++with_tex; - } - } - UtilityFunctions::print(vformat("[Metin2Model] gr2 materials: %d (%d with diffuse name)", - (int)materials.size(), with_tex)); - } - last_info = vformat("gr2 v%d | bones=%d meshes=%d surfaces=%d verts=%d | bounds=%s", - (int)(*file)->format_version(), - sk ? (int)sk->bones.size() : 0, - (int)fi.meshes.size(), - mesh->get_surface_count(), - vtot, - String(bounds)); - if (model_diagnostics()) { - UtilityFunctions::print("[Metin2Model] ", last_info); - } - - _load_lods(gr2_spec); - - if (!hair_gr2.is_empty()) - _load_hair(); - if (!weapon_gr2.is_empty()) - _load_weapon(); - if (!shield_gr2.is_empty()) - _load_shield(); -} - -void Metin2Model::_load_hair() { - hair_parts.clear(); - hair_bone_remap.clear(); - hair_mat.unref(); - hair_tex.unref(); - gpu_base_surf = 0; - if (hair_gr2.is_empty() || !file || !*file) - return; - const gr2::FileInfo &base_fi = (*file)->file_info(); - if (base_fi.skeletons.empty()) - return; - - if (!hair_file) - hair_file = std::make_shared>(); - gr2::LoadError e; - auto h = mtgodot::gr2_from_file(hair_gr2, &e); - if (!h) { - UtilityFunctions::push_warning(String("[Metin2Model] hair load failed: ") + e.message.c_str()); - return; - } - *hair_file = std::move(h); - const gr2::FileInfo &hfi = (**hair_file).file_info(); - if (hfi.skeletons.empty()) - return; - - // hair 骨骼名 -> base 骨骼索引 - const auto &bbones = base_fi.skeletons[0].bones; - const auto &hbones = hfi.skeletons[0].bones; - hair_bone_remap.assign(hbones.size(), -1); - for (size_t i = 0; i < hbones.size(); ++i) - for (size_t j = 0; j < bbones.size(); ++j) - if (hbones[i].name == bbones[j].name) { - hair_bone_remap[i] = (int)j; - break; - } - - hair_parts = mtgodot::build_parts(hfi); - - // hair 贴图:同名 .dds,alpha-scissor 镂空 - // SourceSkin (gr2 sibling .dds) -> TargetSkin override (§2.4) - String hdds = hair_skin.is_empty() ? (hair_gr2.get_basename() + ".dds") : hair_skin; - Ref ht = _load_dds(hdds); - if (ht.is_null() && !hair_skin.is_empty()) { - UtilityFunctions::push_warning(String("[Metin2Model] hair_skin not found: ") + hair_skin); - ht = _load_dds(hair_gr2.get_basename() + ".dds"); // fall back to SourceSkin - } - // R2-02: the same forward material the body/weapon surfaces use, not a - // StandardMaterial3D. The old one ran Godot's PBR specular at roughness 1.0 - // and washed the fringe out into flat cream patches (bisection: roughness 0 - // or unshaded clean, alpha/cull/metallic changes not). make_material's - // shaders are `specular_disabled`, so the hair now lights like the body. - // CPU path: skinning already happened on the CPU, so skinned = false. - mtgodot::MaterialDesc hd; - hd.albedo = ht; - hd.blend = mtgodot::BlendMode::AlphaTest; // discard < 0.5, still depth-opaque - hd.alpha_scissor = 0.5f; - hd.two_sided = true; // hair cards are single-sided geometry - Ref hm = mtgodot::make_material(hd); - if (ht.is_null()) // keep the untextured fallback colour (make_material's is pale grey) - hm->set_shader_parameter("modulate", Color(0.18f, 0.12f, 0.08f, 1.0f)); - hair_mat = hm; - hair_tex = ht; // reused by the GPU-skin path (§2.2) - - int matched = 0; - for (int r : hair_bone_remap) - if (r >= 0) - ++matched; - UtilityFunctions::print(vformat("[Metin2Model] hair: %s %d/%d bones matched, %d parts", - hair_gr2.get_file(), matched, (int)hbones.size(), (int)hair_parts.size())); - -} - -void Metin2Model::_refresh_hair_mesh() { - if (gpu_skin_active) { - _build_gpu_mesh(); - _apply_materials(); - gpu_visual_bounds_ready = false; - if (!current_skin.empty()) gpu_skin(current_skin); - } else if (has_cpu_skin_mesh() && !current_skin.empty()) { - cpu_skin(current_skin); - } -} - -void Metin2Model::set_hair_gr2(const String &p) { - if (p == hair_gr2) - return; - hair_gr2 = p; - if (file && *file) { - _load_hair(); - _refresh_hair_mesh(); - } -} - -void Metin2Model::set_hair_skin(const String &p) { - if (p == hair_skin) - return; - hair_skin = p; - if (file && *file && !hair_gr2.is_empty()) { - _load_hair(); - _refresh_hair_mesh(); - } -} - -void Metin2Model::set_weapon_gr2(const String &p) { - if (p == weapon_gr2) - return; - weapon_gr2 = p; - if (file && *file) - _load_weapon(); -} - -void Metin2Model::set_weapon_bone(const String &b) { - if (b == weapon_bone) - return; - weapon_bone = b; - if (file && *file) - _load_weapon(); -} - -void Metin2Model::set_shield_gr2(const String &p) { - if (p == shield_gr2) - return; - shield_gr2 = p; - if (file && *file) - _load_shield(); -} - -void Metin2Model::set_shield_bone(const String &b) { - if (b == shield_bone) - return; - shield_bone = b; - if (file && *file) - _load_shield(); -} - -void Metin2Model::_load_weapon() { - _load_attach(weapon_gr2, weapon_bone, weapon_file, weapon_mi, weapon_bone_idx, weapon_pre, - "Weapon", "weapon"); -} - -void Metin2Model::_load_shield() { - _load_attach(shield_gr2, shield_bone, shield_file, shield_mi, shield_bone_idx, shield_pre, - "Shield", "shield"); -} - -void Metin2Model::_load_attach(const String &gr2_rel, const String &bone_name, - std::shared_ptr> &slot_file, MeshInstance3D *&slot_mi, - int &slot_bone_idx, gr2::Mat4 &slot_pre, const char *node_name, const char *label) { - if (slot_mi) { - remove_child(slot_mi); - slot_mi->queue_free(); - slot_mi = nullptr; - } - slot_bone_idx = -1; - slot_pre = gr2::Mat4{ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 }; - if (gr2_rel.is_empty() || !file || !*file) - return; - const gr2::FileInfo &base_fi = (*file)->file_info(); - if (base_fi.skeletons.empty()) - return; - - // attach bone: name -> base skeleton index (client: equip_right_hand / _left_) - const auto &bbones = base_fi.skeletons[0].bones; - std::string want(bone_name.utf8().get_data()); - for (size_t j = 0; j < bbones.size(); ++j) - if (bbones[j].name == want) { - slot_bone_idx = (int)j; - break; - } - if (slot_bone_idx < 0) { - UtilityFunctions::push_warning(String("[Metin2Model] ") + label + " bone not found: " + - bone_name); - return; - } - - String wpath = gr2_rel; - if (!FileAccess::file_exists(wpath)) - wpath = resolve_rel_gr2( - resolved_gr2_dir.is_empty() ? gr2_path : resolved_gr2_dir.path_join("x"), gr2_rel); - gr2::LoadError e; - auto w = mtgodot::gr2_from_file(wpath, &e); - if (!w) { - UtilityFunctions::push_warning(String("[Metin2Model] ") + label + " load failed: " + - String(e.message.c_str()) + " (" + wpath + ")"); - return; - } - if (!slot_file) - slot_file = std::make_shared>(); - *slot_file = std::move(w); - const gr2::FileInfo &wfi = (**slot_file).file_info(); - - // Grip pre-transform, mirroring Granny's rigid-mesh composite in the client: - // weaponWorld[0] = weaponLocal[0] · handWorld - // weaponComposite[0] = weaponInverseWorld[0] · weaponWorld[0] - // The explicit parent matrix passed to GrannyBuildWorldPose replaces the - // weapon model's InitialPlacement; multiplying it here would cancel 03150's - // inverse-bind correction. See ModelInstanceUpdate.cpp:148. - // Do not clamp a large composite translation: files such as 03150 also store - // their raw vertices metres away from the skeleton origin, and this authored - // inverse-bind transform is precisely what moves the grip back onto the hand. - if (!wfi.skeletons.empty() && !wfi.skeletons[0].bones.empty()) { - const gr2::Bone &wb0 = wfi.skeletons[0].bones[0]; - slot_pre = mtgodot::mul4x3(wb0.inverse_world, wb0.local_transform); - } - - std::vector wparts = mtgodot::build_parts(wfi); - AABB wb; - Ref wmesh = mtgodot::build_mesh(wfi, wparts, flip_winding, wb); - if (wmesh.is_null() || wmesh->get_surface_count() == 0) - return; - - // textures: gr2 material binding -> sibling .dds by basename - String wdir = wpath.get_base_dir(); - PackedStringArray wdds; - if (Ref da = DirAccess::open(wdir); da.is_valid()) { - da->list_dir_begin(); - for (String f = da->get_next(); !f.is_empty(); f = da->get_next()) - if (!da->current_is_dir() && f.to_lower().ends_with(".dds")) - wdds.push_back(f); - da->list_dir_end(); - } - auto find_wdds = [&](const String &want_base) -> Ref { - for (int i = 0; i < wdds.size(); ++i) - if (wdds[i].get_basename().to_lower() == want_base) - return _load_dds(wdir.path_join(wdds[i])); - return Ref(); - }; - - slot_mi = memnew(MeshInstance3D); - slot_mi->set_name(node_name); - slot_mi->set_layer_mask(1u << 1); // equipment follows the character light. - slot_mi->set_mesh(wmesh); - add_child(slot_mi); - - std::vector wmaterials = gr2::dump_materials(**slot_file); - int wtextured = 0; - for (int s = 0; s < wmesh->get_surface_count(); ++s) { - String want_base; - if (s < (int)wparts.size()) { - const gr2::Mesh &gm = wfi.meshes[wparts[s].mesh]; - if (wparts[s].mat_index >= 0 && wparts[s].mat_index < (int)gm.material_textures.size()) - want_base = String(gm.material_textures[wparts[s].mat_index].c_str()) - .replace("\\", "/") - .get_file() - .get_basename() - .to_lower(); - } - Ref tex = find_wdds(want_base); - if (tex.is_valid()) - ++wtextured; - Ref mat; - if (material_mode == String("standard")) { - Ref sm; - sm.instantiate(); - if (tex.is_valid()) - sm->set_texture(BaseMaterial3D::TEXTURE_ALBEDO, tex); - mat = sm; - } else { - const String wsn = wmesh->surface_get_name(s); - const gr2::MaterialInfo *wm = match_material(wmaterials, want_base); - mtgodot::MaterialDesc md; - md.albedo = tex; - md.blend = decide_blend(wm, wsn); - md.two_sided = decide_two_sided(wm, wsn); - mat = mtgodot::make_material(md); - } - slot_mi->set_surface_override_material(s, mat); - } - - // place immediately at bind pose so a static (non-animated) model still holds it - std::vector bw, bs; - gr2::bind_pose(base_fi.skeletons[0], bw, bs); - update_weapon_pose(bw); - - UtilityFunctions::print(vformat("[Metin2Model] %s: %s -> bone[%d] %s, %d surf (%d textured)", - label, wpath.get_file(), slot_bone_idx, bone_name, wmesh->get_surface_count(), - wtextured)); -} - -void Metin2Model::update_weapon_pose(const std::vector &world_pose) { - current_world_pose = world_pose; - // weapon local = gripPre · handWorld (client: weaponBone[0] composed onto the - // parent attach-bone matrix). gripPre is identity for near-origin weapons, so - // this is backward-compatible with the ones that already looked right. - if (weapon_mi && weapon_bone_idx >= 0 && weapon_bone_idx < (int)world_pose.size()) - weapon_mi->set_transform(mtgodot::gr2_to_godot( - mtgodot::mul4x3(weapon_pre, world_pose[weapon_bone_idx]))); - if (shield_mi && shield_bone_idx >= 0 && shield_bone_idx < (int)world_pose.size()) - shield_mi->set_transform(mtgodot::gr2_to_godot( - mtgodot::mul4x3(shield_pre, world_pose[shield_bone_idx]))); -} - -namespace { inline void xform_pt(const gr2::Mat4 &M, const float *v, float *o); } - -Dictionary Metin2Model::get_fly_target_bounds() { - // Legacy caches its sphere until the model is reset. Do not let particles, - // hair, weapons, LOD ghosts or subsequent animation frames shift the centre. - if (!fly_target_bounds_valid) { - const gr2::FileInfo *fi = active_fi(); - bool found = false; - AABB bounds; - if (fi) for (const auto &mesh : fi->meshes) { - for (size_t slot = 0; slot < mesh.bone_bounds.size(); ++slot) { - const auto &obb = mesh.bone_bounds[slot]; - const int bone = slot < mesh.bone_bindings.size() ? mesh.bone_bindings[slot] : -1; - if (!obb.valid || bone < 0 || bone >= (int)current_world_pose.size()) continue; - for (int corner = 0; corner < 8; ++corner) { - float point[3], transformed[3]; - for (int axis = 0; axis < 3; ++axis) point[axis] = (corner & (1 << axis)) ? obb.max[axis] : obb.min[axis]; - xform_pt(current_world_pose[bone], point, transformed); - const Vector3 p(transformed[0], transformed[1], transformed[2]); - bounds = found ? bounds.expand(p) : AABB(p, Vector3()); - found = true; - } - } - } - if (found) { fly_target_bounds = bounds; fly_target_bounds_valid = true; } - } - Dictionary result; - result["valid"] = fly_target_bounds_valid; - result["bounds"] = fly_target_bounds; - return result; -} - -String Metin2Model::_guess_texture_dir() const { - if (!texture_dir.is_empty()) { - return texture_dir; - } - return resolved_gr2_dir.is_empty() ? gr2_path.get_base_dir() : resolved_gr2_dir; -} - -// Shared sphere map for §2.7 specular. Client sphere index 0 == the global -// "d:/ymir work/special/spheremap.jpg"; in the loose-file packs that lands at -// "/ymir work/special/spheremap.jpg" (typically the ETC pack). Walk up from -// the model dir and probe each level + its immediate subdirs. .jpg decodes via -// Godot core (Image::load), no DDS path needed. -Ref Metin2Model::_load_sphere_map() { - if (sphere_map.is_valid()) { - return sphere_map; - } - static const char *kTail = "ymir work/special/spheremap.jpg"; - String hit; - String dir = _guess_texture_dir(); - for (int up = 0; up < 8 && !dir.is_empty() && dir != "/" && hit.is_empty(); ++up) { - String direct = dir.path_join(kTail); - if (FileAccess::file_exists(direct)) { - hit = direct; - break; - } - Ref da = DirAccess::open(dir); - if (da.is_valid()) { - da->list_dir_begin(); - for (String f = da->get_next(); !f.is_empty(); f = da->get_next()) { - if (!da->current_is_dir() || f.begins_with(".")) { - continue; - } - String cand = dir.path_join(f).path_join(kTail); - if (FileAccess::file_exists(cand)) { - hit = cand; - break; - } - } - da->list_dir_end(); - } - dir = dir.get_base_dir(); - } - if (hit.is_empty()) { - UtilityFunctions::push_warning("[Metin2Model] specular_power set but spheremap.jpg not found"); - return Ref(); - } - Ref img; - img.instantiate(); - if (img->load(hit) != OK) { - UtilityFunctions::push_warning("[Metin2Model] failed to decode " + hit); - return Ref(); - } - sphere_map = ImageTexture::create_from_image(img); - UtilityFunctions::print("[Metin2Model] sphere map: " + hit); - return sphere_map; -} - -// "d:/ymir work/pc2/warrior/x.gr2" (referenced from a .msm/.msa at `base`) -> -// absolute path. Walks up from base's dir trying each level as an asset root -// against "/PC/ymir work/" and "/ymir work/". -String Metin2Model::resolve_rel_gr2(const String &base, const String &spec) { - String p = spec.replace("\\", "/"); - if (FileAccess::file_exists(p)) { // already absolute - return p; - } - String base_dir = base.get_base_dir(); - String sib = base_dir.path_join(p.get_file()); - if (FileAccess::file_exists(sib)) { - return sib; - } - int kp = p.to_lower().find("ymir work/"); - String yw_tail = (kp >= 0) ? p.substr(kp + 10) : p; // "pc2/warrior/x.gr2" - - String dir = base_dir; - String assets_root; - for (int up = 0; up < 8 && !dir.is_empty() && dir != "/"; ++up) { - for (const String &pre : { String("PC/ymir work/"), String("ymir work/"), - String("PC/Ymir Work/"), String("") }) { - String cand = dir.path_join(pre + yw_tail); - if (FileAccess::file_exists(cand)) { - return cand; - } - } - if (assets_root.is_empty()) { - Ref da = DirAccess::open(dir); - if (da.is_valid() && da->dir_exists(dir.path_join("PC"))) { - assets_root = dir; - } - } - dir = dir.get_base_dir(); - } - // eterpack layout: the model may live in another loose-file pack dir - // (assets//ymir work/). Scan the asset root's subdirs. - if (!assets_root.is_empty()) { - Ref da = DirAccess::open(assets_root); - if (da.is_valid()) { - da->list_dir_begin(); - for (String f = da->get_next(); !f.is_empty(); f = da->get_next()) { - if (!da->current_is_dir() || f.begins_with(".")) { - continue; - } - String cand = assets_root.path_join(f).path_join("ymir work/") + yw_tail; - if (FileAccess::file_exists(cand)) { - da->list_dir_end(); - return cand; - } - } - da->list_dir_end(); - } - } - return sib; // best guess -} - -Ref Metin2Model::_load_dds(const String &path) { - Ref t; - if (s_model_tex_cache.has(path)) { - return s_model_tex_cache[path]; - } - if (FileAccess::file_exists(path)) { - mtgodot::Image dds = mtgodot::dds_from_file(path); - if (dds.ok()) { - // Character skins are sRGB colour -> eligible for mobile ASTC - // (mtgodot::make_color_texture; no-op on desktop). §F4. - t = mtgodot::make_color_texture(dds.w, dds.h, dds.rgba.data(), - dds.rgba.size(), /*mipmaps=*/true); - } - } - s_model_tex_cache[path] = t; - return t; -} - -// Best-effort texture pick for a surface. libgr2's POD API gives no material or -// texture names yet (see GODOT-POC-PLAN.md §M2.5 gap list), so this is filename -// convention + a case-insensitive directory scan. -Ref Metin2Model::_resolve_texture(const String &dir, const String &stem, - const String &surface_name, const PackedStringArray &dds_files) { - String sn = surface_name.to_lower(); - - auto scan = [&](const String &needle) -> Ref { - for (int i = 0; i < dds_files.size(); ++i) { - if (dds_files[i].to_lower().find(needle) != -1) { - Ref t = _load_dds(dir.path_join(dds_files[i])); - if (t.is_valid()) { - return t; - } - } - } - return Ref(); - }; - - // 1. surface-name keyword slots - if (sn.find("face") != -1) { - Ref t = _load_dds(dir.path_join(stem + String("_face.dds"))); - if (t.is_null()) { - t = scan("face"); - } - if (t.is_valid()) { - return t; - } - } - if (sn.find("hair") != -1) { - Ref t = _load_dds(dir.path_join(stem + String("_hair.dds"))); - if (t.is_null()) { - t = scan("hair"); - } - if (t.is_valid()) { - return t; - } - } - // 2. surface name used verbatim as a texture basename - if (!sn.is_empty()) { - Ref t = _load_dds(dir.path_join(surface_name + String(".dds"))); - if (t.is_valid()) { - return t; - } - } - // 3. exact stem - Ref t = _load_dds(dir.path_join(stem + String(".dds"))); - if (t.is_valid()) { - return t; - } - // 4. any dds whose name contains the stem, then any non-face/hair dds - t = scan(stem); - if (t.is_valid()) { - return t; - } - for (int i = 0; i < dds_files.size(); ++i) { - String f = dds_files[i].to_lower(); - if (f.find("face") == -1 && f.find("hair") == -1 && f.find("_lod") == -1) { - Ref any = _load_dds(dir.path_join(dds_files[i])); - if (any.is_valid()) { - return any; - } - } - } - return Ref(); -} - -static mtgodot::BlendMode guess_blend(const String &surface_name) { - String sn = surface_name.to_lower(); - if (sn.find("effect") != -1 || sn.find("glow") != -1 || sn.find("aura") != -1) { - return mtgodot::BlendMode::Add; - } - if (sn.find("hair") != -1) { - return mtgodot::BlendMode::AlphaTest; // Metin2 hair = alpha-test cutout - } - if (sn.find("cloak") != -1 || sn.find("cape") != -1 || - sn.find("skirt") != -1 || sn.find("ribbon") != -1) { - return mtgodot::BlendMode::Alpha; - } - return mtgodot::BlendMode::Opaque; -} - -// gr2 material for a mesh-local texture basename: match dump_materials() by the -// diffuse_texture / material name (both reduced to lowercase basename). -static const gr2::MaterialInfo *match_material(const std::vector &mats, - const String &want_base) { - if (want_base.is_empty()) { - return nullptr; - } - std::string w(want_base.utf8().get_data()); - for (const auto &m : mats) { - String dt = String(m.diffuse_texture.c_str()) - .replace("\\", "/") - .get_file() - .get_basename() - .to_lower(); - if (dt == want_base || String(m.name.c_str()).to_lower() == want_base) { - return &m; - } - } - return nullptr; -} - -// Blend mode: prefer the GR2 material flag (EterGrnLib Material.cpp:233 = Name -// "Blend*" + MapCount>1); fall back to the surface-name heuristic for the cases -// GR2 doesn't encode (hair cutout, effect additive, cloth alpha). -static mtgodot::BlendMode decide_blend(const gr2::MaterialInfo *m, const String &sname) { - String sn = sname.to_lower(); - if (sn.find("effect") != -1 || sn.find("glow") != -1 || sn.find("aura") != -1) { - return mtgodot::BlendMode::Add; - } - if (sn.find("hair") != -1) { - return mtgodot::BlendMode::AlphaTest; - } - if (m && m->alpha_blend) { - return mtgodot::BlendMode::Alpha; - } - return guess_blend(sname); -} - -static bool decide_two_sided(const gr2::MaterialInfo *m, const String &sname) { - if (m && m->two_sided) { - return true; - } - String sn = sname.to_lower(); - return sn.find("hair") != -1 || sn.find("cloak") != -1 || sn.find("cape") != -1 || - sn.find("skirt") != -1 || sn.find("ribbon") != -1 || sn.find("leaf") != -1; -} - -void Metin2Model::_apply_materials() { - if (!mi || !file || !*file) { - return; - } - const String dir = _guess_texture_dir(); - const String stem = gr2_path.get_file().get_basename(); - - PackedStringArray dds_files; - if (s_model_dir_dds_cache.has(dir)) { - dds_files = s_model_dir_dds_cache[dir]; - } else { - Ref da = DirAccess::open(dir); - if (da.is_valid()) { - da->list_dir_begin(); - for (String f = da->get_next(); !f.is_empty(); f = da->get_next()) { - if (!da->current_is_dir() && f.to_lower().ends_with(".dds")) { - dds_files.push_back(f); - } - } - da->list_dir_end(); - } - s_model_dir_dds_cache[dir] = dds_files; - } - - // Surface s <-> parts[s]. tri_group.material_index is a MESH-LOCAL index into - // that mesh's MaterialBindings, so the texture name comes from - // Mesh::material_textures[mat_index] (NOT the global dump_materials() list — - // that mixed body/face up). Use the active LOD's fileinfo (§2.10). - const gr2::FileInfo *afi_ = active_fi(); - if (!afi_) { - return; - } - const gr2::FileInfo &fi = *afi_; - - auto find_dds = [&](const String &want) -> Ref { - if (want.is_empty()) { - return Ref(); - } - for (int i = 0; i < dds_files.size(); ++i) { - if (dds_files[i].get_basename().to_lower() == want) { - return _load_dds(dir.path_join(dds_files[i])); - } - } - // Expansion armors share face textures with the base PC pack. - // Resolve that exact name before falling back to unrelated local DDS. - String cls = gr2_path.get_base_dir().get_file(); - for (const String &pc : { String("pc"), String("pc2") }) { - String spec = "d:/ymir work/" + pc + "/" + cls + "/" + want + ".dds"; - String resolved = resolve_rel_gr2(gr2_path, spec); - Ref shared = _load_dds(resolved); - if (shared.is_valid()) { - return shared; - } - } - return Ref(); - }; - - Ref mesh = mi->get_mesh(); - int textured = 0; - const int nsurf = mesh->get_surface_count(); - surf_mats.assign(nsurf, Ref()); - // §2.7 sphere-map specular: applied uniformly to all skin parts here (no - // per-part item_proto to distinguish body armor from face/hair). 0 = skip. - Ref smap = - (specular_power > 0.0 && material_mode != String("standard")) ? _load_sphere_map() - : Ref(); - for (int s = 0; s < nsurf; ++s) { - const String sname = mesh->surface_get_name(s); - Ref tex; - - // §2.2: appended GPU-hair surfaces — skinned shader + hair albedo + cutout. - if (gpu_skin_active && s >= gpu_base_surf) { - mtgodot::MaterialDesc md; - md.albedo = hair_tex; - md.blend = mtgodot::BlendMode::AlphaTest; - md.alpha_scissor = 0.5f; - md.two_sided = true; // Match CPU hair cards when viewed from behind. - md.skinned = true; - md.bones_tex = bones_tex; - Ref hm = mtgodot::make_material(md); - surf_mats[s] = hm; - mi->set_surface_override_material(s, hm); - continue; - } - - String mat_want; // mesh-local material -> dds basename - if (s < (int)parts.size()) { - const mtgodot::RenderPart &rp = parts[s]; - const gr2::Mesh &gm = fi.meshes[rp.mesh]; - if (rp.mat_index >= 0 && rp.mat_index < (int)gm.material_textures.size()) { - mat_want = String(gm.material_textures[rp.mat_index].c_str()) - .replace("\\", "/") - .get_file() - .get_basename() - .to_lower(); - } - } - - if (s < surface_tex_override.size() && !String(surface_tex_override[s]).is_empty()) { - tex = _load_dds(surface_tex_override[s]); - } - // `.msm` SourceSkin -> TargetSkin remaps are material-name based in the - // 40250 client. An armour can have face/body/effect surfaces in a - // different order, so applying TargetSkin to hard-coded surface 0 gives - // the right geometry with the wrong (usually base black) appearance. - if (tex.is_null() && skin_tex_override.has(mat_want)) { - tex = _load_dds(String(skin_tex_override[mat_want])); - } - // gr2 material binding -> texture (default; set use_gr2_materials=false to skip) - if (tex.is_null() && use_gr2_materials) { - tex = find_dds(mat_want); - } - // filename-convention heuristic (fallback when the binding resolved nothing) - if (tex.is_null()) { - tex = _resolve_texture(dir, stem, sname, dds_files); - } - if (tex.is_valid()) { - ++textured; - } - - Ref mat; - if (material_mode == String("standard")) { - Ref sm; - sm.instantiate(); - sm->set_cull_mode(BaseMaterial3D::CULL_DISABLED); - if (tex.is_valid()) { - sm->set_texture(BaseMaterial3D::TEXTURE_ALBEDO, tex); - } else { - sm->set_albedo(Color(0.8, 0.8, 0.82)); - } - mat = sm; - } else { - const gr2::MaterialInfo *gm_mat = match_material(materials, mat_want); - mtgodot::MaterialDesc md; - md.albedo = tex; - md.blend = decide_blend(gm_mat, sname); - md.two_sided = decide_two_sided(gm_mat, sname); - md.skinned = gpu_skin_active; - md.bones_tex = bones_tex; - if (smap.is_valid()) { - md.spec_map = smap; - md.spec_power = (float)specular_power; - } - mat = mtgodot::make_material(md); - } - surf_mats[s] = mat; - mi->set_surface_override_material(s, mat); - } - if (model_diagnostics()) { - UtilityFunctions::print(vformat("[Metin2Model] materials: %d/%d surfaces textured (dir=%s, %d dds)", - textured, mesh->get_surface_count(), dir, (int)dds_files.size())); - } -} - -void Metin2Model::_build_gpu_mesh() { - if (!mi || !file || !*file) { - return; - } - const gr2::FileInfo *bfi = active_fi(); - if (!bfi) { - return; - } - AABB b; - Ref combined = build_mesh(*bfi, parts, flip_winding, b); - gpu_base_surf = combined->get_surface_count(); - - if (hair_file && *hair_file && !hair_parts.empty() && !hair_bone_remap.empty()) { - const gr2::FileInfo &hfi = (**hair_file).file_info(); - AABB hb; - Ref hm = build_mesh(hfi, hair_parts, flip_winding, hb); - for (int s = 0; s < hm->get_surface_count(); ++s) { - Array arr = hm->surface_get_arrays(s); - PackedInt32Array bones = arr[Mesh::ARRAY_BONES]; - for (int k = 0; k < bones.size(); ++k) { - int hi = bones[k]; - int bi = (hi >= 0 && hi < (int)hair_bone_remap.size()) ? hair_bone_remap[hi] : -1; - bones.set(k, bi >= 0 ? bi : 0); - } - arr[Mesh::ARRAY_BONES] = bones; - combined->add_surface_from_arrays(Mesh::PRIMITIVE_TRIANGLES, arr); - combined->surface_set_name(combined->get_surface_count() - 1, - String("hair_") + String::num_int64(s)); - } - } - mi->set_mesh(combined); -} - -const gr2::Skeleton *Metin2Model::gr2_skeleton() const { - if (!file || !*file) { - return nullptr; - } - const gr2::FileInfo &fi = (*file)->file_info(); - return fi.skeletons.empty() ? nullptr : &fi.skeletons[0]; -} - -const gr2::FileInfo *Metin2Model::gr2_fileinfo() const { - return (file && *file) ? &(*file)->file_info() : nullptr; -} - -namespace { -// row-major 4x4 (translation elem 12..14), row-vector: out = [v 1] * M -inline void xform_pt(const gr2::Mat4 &M, const float *v, float *o) { - o[0] = v[0] * M[0] + v[1] * M[4] + v[2] * M[8] + M[12]; - o[1] = v[0] * M[1] + v[1] * M[5] + v[2] * M[9] + M[13]; - o[2] = v[0] * M[2] + v[1] * M[6] + v[2] * M[10] + M[14]; -} -inline void xform_dir(const gr2::Mat4 &M, const float *v, float *o) { - o[0] = v[0] * M[0] + v[1] * M[4] + v[2] * M[8]; - o[1] = v[0] * M[1] + v[1] * M[5] + v[2] * M[9]; - o[2] = v[0] * M[2] + v[1] * M[6] + v[2] * M[10]; -} -} // namespace - -void Metin2Model::enable_cpu_skin(bool on) { - if (!mi || !file || !*file) { - return; - } - if (on && cpu_mesh.is_null()) { - AABB b; - const gr2::FileInfo *afi_ = active_fi(); - if (!afi_) { - return; - } - if (parts.empty()) { - parts = mtgodot::build_parts(*afi_); - } - cpu_mesh = build_mesh(*afi_, parts, flip_winding, b); - mi->set_skeleton_path(NodePath()); - mi->set_skin(Ref()); - mi->set_mesh(cpu_mesh); - if (skel) { - skel->set_show_rest_only(true); - } - if (model_diagnostics()) { - UtilityFunctions::print("[Metin2Model] CPU-skin mode ON"); - } - } -} - -void Metin2Model::cpu_skin(const std::vector &skin) { - if (cpu_mesh.is_null() || !file || !*file) { - return; - } - const gr2::FileInfo *afi_ = active_fi(); - if (!afi_) { - return; - } - const gr2::FileInfo &fi = *afi_; // active LOD mesh data (§2.10) - current_skin = skin; - static const gr2::Mat4 I{ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 }; - - // Skin each referenced mesh's vertices once; parts of the same mesh share them. - std::vector mesh_pos(fi.meshes.size()); - std::vector mesh_nrm(fi.meshes.size()); - std::vector mesh_uv(fi.meshes.size()); - std::vector skinned(fi.meshes.size(), false); - AABB animated_bounds; - bool have_animated_bounds = false; - auto skin_mesh = [&](int midx) { - if (skinned[midx]) { - return; - } - skinned[midx] = true; - const gr2::Mesh &m = fi.meshes[midx]; - const int nv = (int)m.vertices.size(); - mesh_pos[midx].resize(nv); - mesh_nrm[midx].resize(nv); - mesh_uv[midx].resize(nv); - Vector3 *pw = mesh_pos[midx].ptrw(); - Vector3 *nw = mesh_nrm[midx].ptrw(); - Vector2 *uw = mesh_uv[midx].ptrw(); - auto bone_of = [&](int slot) -> const gr2::Mat4 & { - if (slot < 0 || slot >= (int)m.bone_bindings.size()) { - return I; - } - int b = m.bone_bindings[slot]; - return (b < 0 || b >= (int)skin.size()) ? I : skin[b]; - }; - for (int i = 0; i < nv; ++i) { - const gr2::Vertex &v = m.vertices[i]; - float wq[4]; - float sw = 0; - for (int k = 0; k < 4; ++k) { - sw += v.bone_weight[k]; - } - for (int k = 0; k < 4; ++k) { - wq[k] = (sw > 0) ? v.bone_weight[k] / sw : (k == 0 ? 1.f : 0.f); - } - gr2::Mat4 M{}; - for (int k = 0; k < 4; ++k) { - const gr2::Mat4 &bm = bone_of(v.bone_index[k]); - for (int e = 0; e < 16; ++e) { - M[e] += wq[k] * bm[e]; - } - } - gr2::Mat4 M3 = M; - M3[12] = M3[13] = M3[14] = 0; - float p[3], nn[3]; - xform_pt(M, v.pos, p); - xform_dir(M3, v.normal, nn); - float ln = std::sqrt(nn[0] * nn[0] + nn[1] * nn[1] + nn[2] * nn[2]); - if (ln > 1e-8f) { - nn[0] /= ln; - nn[1] /= ln; - nn[2] /= ln; - } - pw[i] = Vector3(p[0], p[1], p[2]); - if (std::isfinite(p[0]) && std::isfinite(p[1]) && std::isfinite(p[2])) { - if (have_animated_bounds) { - animated_bounds = animated_bounds.expand(pw[i]); - } else { - animated_bounds = AABB(pw[i], Vector3()); - have_animated_bounds = true; - } - } - nw[i] = Vector3(nn[0], nn[1], nn[2]); - uw[i] = Vector2(v.uv0[0], v.uv0[1]); - } - }; - - cpu_mesh->clear_surfaces(); - for (const mtgodot::RenderPart &rp : parts) { - skin_mesh(rp.mesh); - const gr2::Mesh &m = fi.meshes[rp.mesh]; - const uint32_t ib = rp.idx_first; - const uint32_t ic = (rp.idx_count && ib + rp.idx_count <= m.indices.size()) - ? rp.idx_count - : (uint32_t)m.indices.size() - ib; - PackedInt32Array idx; - idx.resize((int)ic); - int32_t *iw = idx.ptrw(); - for (uint32_t t = 0; t < ic; ++t) { - iw[t] = (int)m.indices[ib + t]; - } - // Match build_mesh(): CPU animation must preserve the configured winding. - if (flip_winding) - for (uint32_t t = 0; t + 2 < ic; t += 3) - std::swap(iw[t + 1], iw[t + 2]); - Array arrays; - arrays.resize(Mesh::ARRAY_MAX); - arrays[Mesh::ARRAY_VERTEX] = mesh_pos[rp.mesh]; - arrays[Mesh::ARRAY_NORMAL] = mesh_nrm[rp.mesh]; - arrays[Mesh::ARRAY_TEX_UV] = mesh_uv[rp.mesh]; - arrays[Mesh::ARRAY_INDEX] = idx; - cpu_mesh->add_surface_from_arrays(Mesh::PRIMITIVE_TRIANGLES, arrays); - } - const int body_surfaces = cpu_mesh->get_surface_count(); - - // --- hair pass: skin the hair meshes with the base skeleton (bones remapped by name) --- - int hair_surfaces = 0; - if (hair_file && *hair_file && !hair_parts.empty()) { - const gr2::FileInfo &hfi = (**hair_file).file_info(); - std::vector hp(hfi.meshes.size()), hn(hfi.meshes.size()); - std::vector hu(hfi.meshes.size()); - std::vector hs(hfi.meshes.size(), false); - auto skin_hair = [&](int midx) { - if (hs[midx]) - return; - hs[midx] = true; - const gr2::Mesh &m = hfi.meshes[midx]; - const int nv = (int)m.vertices.size(); - hp[midx].resize(nv); - hn[midx].resize(nv); - hu[midx].resize(nv); - Vector3 *pw = hp[midx].ptrw(); - Vector3 *nw = hn[midx].ptrw(); - Vector2 *uw = hu[midx].ptrw(); - auto bone_of = [&](int slot) -> const gr2::Mat4 & { - if (slot < 0 || slot >= (int)m.bone_bindings.size()) - return I; - int hb = m.bone_bindings[slot]; // hair-skel index - int bb = (hb >= 0 && hb < (int)hair_bone_remap.size()) ? hair_bone_remap[hb] : -1; - return (bb < 0 || bb >= (int)skin.size()) ? I : skin[bb]; - }; - for (int i = 0; i < nv; ++i) { - const gr2::Vertex &v = m.vertices[i]; - float sw = 0, wq[4]; - for (int k = 0; k < 4; ++k) - sw += v.bone_weight[k]; - for (int k = 0; k < 4; ++k) - wq[k] = (sw > 0) ? v.bone_weight[k] / sw : (k == 0 ? 1.f : 0.f); - gr2::Mat4 M{}; - for (int k = 0; k < 4; ++k) { - const gr2::Mat4 &bm = bone_of(v.bone_index[k]); - for (int e = 0; e < 16; ++e) - M[e] += wq[k] * bm[e]; - } - gr2::Mat4 M3 = M; - M3[12] = M3[13] = M3[14] = 0; - float p[3], nn[3]; - xform_pt(M, v.pos, p); - xform_dir(M3, v.normal, nn); - float ln = std::sqrt(nn[0] * nn[0] + nn[1] * nn[1] + nn[2] * nn[2]); - if (ln > 1e-8f) { - nn[0] /= ln; - nn[1] /= ln; - nn[2] /= ln; - } - pw[i] = Vector3(p[0], p[1], p[2]); - if (std::isfinite(p[0]) && std::isfinite(p[1]) && std::isfinite(p[2])) { - if (have_animated_bounds) { - animated_bounds = animated_bounds.expand(pw[i]); - } else { - animated_bounds = AABB(pw[i], Vector3()); - have_animated_bounds = true; - } - } - nw[i] = Vector3(nn[0], nn[1], nn[2]); - uw[i] = Vector2(v.uv0[0], v.uv0[1]); - } - }; - for (const mtgodot::RenderPart &rp : hair_parts) { - if (rp.mesh < 0 || rp.mesh >= (int)hfi.meshes.size()) - continue; - skin_hair(rp.mesh); - const gr2::Mesh &m = hfi.meshes[rp.mesh]; - const uint32_t ib = rp.idx_first; - const uint32_t ic = (rp.idx_count && ib + rp.idx_count <= m.indices.size()) - ? rp.idx_count - : (uint32_t)m.indices.size() - ib; - PackedInt32Array idx; - idx.resize((int)ic); - int32_t *iw = idx.ptrw(); - for (uint32_t t = 0; t < ic; ++t) - iw[t] = (int)m.indices[ib + t]; - if (flip_winding) - for (uint32_t t = 0; t + 2 < ic; t += 3) - std::swap(iw[t + 1], iw[t + 2]); - Array a; - a.resize(Mesh::ARRAY_MAX); - a[Mesh::ARRAY_VERTEX] = hp[rp.mesh]; - a[Mesh::ARRAY_NORMAL] = hn[rp.mesh]; - a[Mesh::ARRAY_TEX_UV] = hu[rp.mesh]; - a[Mesh::ARRAY_INDEX] = idx; - cpu_mesh->add_surface_from_arrays(Mesh::PRIMITIVE_TRIANGLES, a); - ++hair_surfaces; - } - } - - // clear_surfaces() dropped the override materials — just re-assign the cached - // ones. surf_mats is sized to the full surface count (body + folded hair) by - // _apply_materials(); here we only rebind the body slots, so `>=` is the - // right test. Using `==` made this fall through to _apply_materials() EVERY - // frame whenever hair was present (surf_mats bigger than body_surfaces) — - // reloading every DDS per frame, stalling the frame loop (missed PONGs -> - // server disconnect) and thrashing the mesh. - if (body_surfaces > 0 && (int)surf_mats.size() >= body_surfaces) { - for (int s = 0; s < body_surfaces; ++s) - mi->set_surface_override_material(s, surf_mats[s]); - } else { - _apply_materials(); - } - for (int s = 0; s < hair_surfaces; ++s) - mi->set_surface_override_material(body_surfaces + s, hair_mat); - if (mi && have_animated_bounds) { - mi->set_custom_aabb(animated_bounds.grow(0.01)); - visual_bounds = animated_bounds; - have_visual_bounds = true; - } -} - -bool Metin2Model::_update_skinned_bounds(const std::vector &skin) { - const gr2::FileInfo *afi = active_fi(); - if (!afi) { - return false; - } - static const gr2::Mat4 I{ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 }; - AABB bounds; - bool have_bounds = false; - auto expand_mesh = [&](const gr2::FileInfo &fi, const std::vector *remap) { - for (const gr2::Mesh &m : fi.meshes) { - for (const gr2::Vertex &v : m.vertices) { - gr2::Mat4 blend{}; - float sum = 0.0f; - for (int k = 0; k < 4; ++k) sum += v.bone_weight[k]; - for (int k = 0; k < 4; ++k) { - const float w = sum > 0.0f ? v.bone_weight[k] / sum : (k == 0 ? 1.0f : 0.0f); - if (w == 0.0f) continue; - int bone = (v.bone_index[k] >= 0 && v.bone_index[k] < (int)m.bone_bindings.size()) - ? m.bone_bindings[v.bone_index[k]] : -1; - if (remap && bone >= 0 && bone < (int)remap->size()) bone = (*remap)[bone]; - const gr2::Mat4 &mat = bone >= 0 && bone < (int)skin.size() ? skin[bone] : I; - for (int e = 0; e < 16; ++e) blend[e] += w * mat[e]; - } - float p[3]; - xform_pt(blend, v.pos, p); - if (!std::isfinite(p[0]) || !std::isfinite(p[1]) || !std::isfinite(p[2])) continue; - const Vector3 point(p[0], p[1], p[2]); - bounds = have_bounds ? bounds.expand(point) : AABB(point, Vector3()); - have_bounds = true; - } - } - }; - expand_mesh(*afi, nullptr); - if (hair_file && *hair_file && !hair_parts.empty() && !hair_bone_remap.empty()) { - expand_mesh((**hair_file).file_info(), &hair_bone_remap); - } - if (have_bounds) { - visual_bounds = bounds; - have_visual_bounds = true; - if (mi) mi->set_custom_aabb(bounds.grow(0.01)); - } - return have_bounds; -} - -void Metin2Model::enable_gpu_skin(bool on) { - if (!on || gpu_skin_active || !mi || !file || !*file) { - return; - } - const gr2::FileInfo &fi = (*file)->file_info(); - const int nbones = fi.skeletons.empty() ? 0 : (int)fi.skeletons[0].bones.size(); - if (nbones == 0) { - return; - } - bones_img = godot::Image::create_empty(3, nbones, false, godot::Image::FORMAT_RGBAF); - bones_tex = ImageTexture::create_from_image(bones_img); - - // §2.2: fold the attached hair into the GPU mesh (bind pose, bone indices - // remapped to the base skeleton). No shader change — SRC_SKIN + the shared - // bones_tex deform it. No hair -> mesh unchanged. - gpu_base_surf = mi->get_mesh().is_valid() ? mi->get_mesh()->get_surface_count() : 0; - if (hair_file && *hair_file && !hair_parts.empty() && !hair_bone_remap.empty()) { - _build_gpu_mesh(); - } - - mi->set_skeleton_path(NodePath()); // no Skeleton3D skinning — the shader does it - mi->set_skin(Ref()); - if (skel) { - skel->set_show_rest_only(true); - } - // gpu_skin() supplies the first actual posed bounds once the animation - // sampler has produced its skin matrices. - gpu_skin_active = true; - _apply_materials(); // rebuild surface materials with the skinned shader + bones_tex - if (model_diagnostics()) { - UtilityFunctions::print(vformat("[Metin2Model] GPU-skin mode ON (%d bones)", nbones)); - } -} - -void Metin2Model::gpu_skin(const std::vector &skin) { - if (!gpu_skin_active || bones_img.is_null()) { - return; - } - current_skin = skin; - // Culling must follow every pose, including offscreen actors and one-shots. - // Only the initial ready notification reframes selection cameras; emitting - // it on every pose would make the camera follow the breathing animation. - const bool bounds_updated = _update_skinned_bounds(skin); - if (!gpu_visual_bounds_ready && bounds_updated) { - gpu_visual_bounds_ready = true; - emit_signal("visual_bounds_changed"); - } - const int nbones = bones_img->get_height(); - PackedByteArray buf; - buf.resize(nbones * 3 * 4 * 4); // rows * 3 texels * RGBA * float - float *f = reinterpret_cast(buf.ptrw()); - static const gr2::Mat4 I{ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 }; - for (int b = 0; b < nbones; ++b) { - const gr2::Mat4 &m = (b < (int)skin.size()) ? skin[b] : I; - float *r = f + b * 12; - // texel j = column j of the row-vector skin matrix ( [pos 1] * M ) - r[0] = m[0]; r[1] = m[4]; r[2] = m[8]; r[3] = m[12]; - r[4] = m[1]; r[5] = m[5]; r[6] = m[9]; r[7] = m[13]; - r[8] = m[2]; r[9] = m[6]; r[10] = m[10]; r[11] = m[14]; - } - bones_img->set_data(3, nbones, false, godot::Image::FORMAT_RGBAF, buf); - bones_tex->update(bones_img); -} - -String Metin2Model::get_info() const { - return last_info; -} - -AABB Metin2Model::get_visual_aabb() { - // Both animation paths publish bounds when sampling, before camera queries. - if (have_visual_bounds) { - return visual_bounds; - } - return mi && mi->get_mesh().is_valid() ? mi->get_mesh()->get_aabb() : AABB(); -} - -String Metin2Model::probe_gr2(const String &path) const { - gr2::LoadError err; - auto f = mtgodot::gr2_from_file(path, &err); - if (!f) { - return vformat("gr2 load FAILED [%s]: %s", String(err.stage.c_str()), String(err.message.c_str())); - } - return vformat("gr2 OK: format_version=%d sections=%d", - (int)f->format_version(), (int)f->sections().size()); -} - -} // namespace mtgodot diff --git a/extension/src/metin2_model.h b/extension/src/metin2_model.h deleted file mode 100644 index a09139f1..00000000 --- a/extension/src/metin2_model.h +++ /dev/null @@ -1,277 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include - -#include "gr2_bridge.h" - -#include -#include -#include - -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 _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 &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 ¤t_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 &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 &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 sphere_map; // shared sphere map, lazy-loaded - godot::Ref _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> hair_file; // loaded hair gr2 - std::vector hair_parts; - std::vector hair_bone_remap; // hair skel idx -> base skel idx (by name) - godot::Ref hair_mat; - godot::Ref 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> 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> 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> &slot_file, godot::MeshInstance3D *&slot_mi, - int &slot_bone_idx, gr2::Mat4 &slot_pre, const char *node_name, const char *label); - - std::shared_ptr> file; // shared_ptr so accessor stays valid - std::vector 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>> lod_files; // [0]=_lod_01 ... - std::vector> 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> lod_ghost_mats; - void _end_lod_fade(); - std::vector 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 parts; // surface s -> (gr2 mesh, tri_group, material) - std::vector> surf_mats; // resolved once; re-assigned each cpu_skin frame - godot::Ref 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 current_world_pose; - godot::AABB fly_target_bounds; - bool fly_target_bounds_valid = false; - std::vector current_skin; - godot::Ref bones_img; // RGBAF 3 x bone_count - godot::Ref bones_tex; - - void _clear_children(); - void _apply_materials(); - bool _update_skinned_bounds(const std::vector &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 _load_dds(const godot::String &path); - godot::Ref _resolve_texture(const godot::String &dir, - const godot::String &stem, const godot::String &surface_name, - const godot::PackedStringArray &dds_files); -}; - -} // namespace mtgodot diff --git a/extension/src/metin2_world.cpp b/extension/src/metin2_world.cpp deleted file mode 100644 index 21e2675b..00000000 --- a/extension/src/metin2_world.cpp +++ /dev/null @@ -1,1218 +0,0 @@ -#include "metin2_world.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include "asset_io.h" -#include "gr2_bridge.h" - -#include -#include -#include -#include -#include -#include -#include - -#include "dxt.h" -#include "environment_builder.h" -#include "static_object.h" -#include "water_builder.h" -#include "terrain_splat.h" -#include "tree_placeholder.h" - -#include -#include - -#include -#include -#include -#include - -using namespace godot; - -namespace mtgodot { - -namespace { - -// AttributeData stores rotations as D3DX quaternions in Metin2's Z-up -// coordinate system. Convert through the same axis change as m2coord positions. -Basis attribute_quaternion_basis(const std::array &q) { - double x = q[0], y = q[1], z = q[2], w = q[3]; - double length = std::sqrt(x * x + y * y + z * z + w * w); - if (length < 1e-8) - return Basis(); - x /= length; y /= length; z /= length; w /= length; - const double r[9] = { - 1.0 - 2.0 * (y * y + z * z), 2.0 * (x * y - z * w), 2.0 * (x * z + y * w), - 2.0 * (x * y + z * w), 1.0 - 2.0 * (x * x + z * z), 2.0 * (y * z - x * w), - 2.0 * (x * z - y * w), 2.0 * (y * z + x * w), 1.0 - 2.0 * (x * x + y * y), - }; - const fmt::m2coord::Mat3 m2{{r[0], r[1], r[2], r[3], r[4], r[5], r[6], r[7], r[8]}}; - const fmt::m2coord::Mat3 c{{1, 0, 0, 0, 0, 1, 0, -1, 0}}; - const fmt::m2coord::Mat3 ci{{1, 0, 0, 0, 0, -1, 0, 1, 0}}; - auto mul = [](const fmt::m2coord::Mat3 &a, const fmt::m2coord::Mat3 &b) { - fmt::m2coord::Mat3 result{}; - for (int i = 0; i < 3; ++i) - for (int j = 0; j < 3; ++j) - for (int k = 0; k < 3; ++k) - result.m[i * 3 + j] += a.m[i * 3 + k] * b.m[k * 3 + j]; - return result; - }; - const fmt::m2coord::Mat3 godot = mul(mul(c, m2), ci); - return Basis(Vector3(godot.m[0], godot.m[3], godot.m[6]), - Vector3(godot.m[1], godot.m[4], godot.m[7]), - Vector3(godot.m[2], godot.m[5], godot.m[8])); -} - -Vector3 attribute_position(const std::array &p) { - const fmt::m2coord::Vec3 v = fmt::m2coord::position_to_godot(p[0], p[1], p[2]); - return Vector3((float)v.x, (float)v.y, (float)v.z); -} - -bool add_attribute_collision(const fmt::AttributeCollision &collision, - StaticBody3D *body) { - using Type = fmt::AttributeCollisionType; - const auto type = static_cast(collision.type); - Ref shape; - Transform3D local(attribute_quaternion_basis(collision.quaternion), - attribute_position(collision.position)); - const float cm = 0.01f; - const float thickness = 0.04f; // finite-plane approximation, 4 cm - - switch (type) { - case Type::Plane: { - Ref plane; - plane.instantiate(); - plane->set_size(Vector3(std::max(0.02f, std::abs(collision.dimensions[0]) * cm), - thickness, std::max(0.02f, std::abs(collision.dimensions[1]) * cm))); - shape = plane; - break; - } - case Type::Box: - case Type::Aabb: - case Type::Obb: { - Ref box; - box.instantiate(); - // CollisionData stores these as half-extents. Swap source y/z for Godot. - box->set_size(Vector3(std::max(0.02f, std::abs(collision.dimensions[0]) * 2.0f * cm), - std::max(0.02f, std::abs(collision.dimensions[2]) * 2.0f * cm), - std::max(0.02f, std::abs(collision.dimensions[1]) * 2.0f * cm))); - shape = box; - if (type == Type::Aabb) - local.basis = Basis(); - break; - } - case Type::Sphere: { - Ref sphere; - sphere.instantiate(); - sphere->set_radius(std::max(0.01f, std::abs(collision.dimensions[0]) * cm)); - shape = sphere; - local.basis = Basis(); - break; - } - case Type::Cylinder: { - Ref cylinder; - cylinder.instantiate(); - cylinder->set_radius(std::max(0.01f, std::abs(collision.dimensions[0]) * cm)); - cylinder->set_height(std::max(0.02f, std::abs(collision.dimensions[1]) * cm)); - shape = cylinder; - break; - } - default: - return false; - } - - CollisionShape3D *node = memnew(CollisionShape3D); - node->set_name(String(collision.name.c_str())); - node->set_shape(shape); - node->set_transform(local); - body->add_child(node); - return true; -} - -inline float cross_product_2d(float x1, float y1, float x2, float y2) { - return x1 * y2 - y1 * x2; -} - -inline bool is_in_triangle_2d(float ax, float ay, float bx, float by, float cx, float cy, float tx, float ty) { - float c1 = cross_product_2d(bx - ax, by - ay, tx - ax, ty - ay); - float c2 = cross_product_2d(cx - bx, cy - by, tx - bx, ty - by); - float c3 = cross_product_2d(ax - cx, ay - cy, tx - cx, ty - cy); - - if (c1 * c2 > 0.0f && c1 * c3 > 0.0f) - return true; - - if (c1 * c2 * c3 == 0.0f) { - if (tx < ax && tx < bx && tx < cx) return false; - if (tx > ax && tx > bx && tx > cx) return false; - if (ty < ay && ty < by && ty < cy) return false; - if (ty > ay && ty > by && ty > cy) return false; - return true; - } - - return false; -} - -void extract_height_triangles(const fmt::AttributeData &data, const Transform3D &place, - std::vector &out_heights) { - for (const auto &height : data.heights) { - if (height.vertices.size() < 3) - continue; - for (size_t i = 0; i + 2 < height.vertices.size(); i += 3) { - Vector3 v0 = place.xform(attribute_position(height.vertices[i])); - Vector3 v1 = place.xform(attribute_position(height.vertices[i + 1])); - Vector3 v2 = place.xform(attribute_position(height.vertices[i + 2])); - Vector3 line1 = v1 - v0; - Vector3 line2 = v2 - v0; - Vector3 normal = line1.cross(line2); - float len = normal.length(); - if (len < 1e-6f) - continue; - normal /= len; - if (std::abs(normal.y) < 0.001f) - continue; // vertical or near-vertical surface, not a walkable floor/deck - Metin2World::HeightTriangle tri; - tri.v0 = v0; - tri.v1 = v1; - tri.v2 = v2; - tri.min_x = std::min({v0.x, v1.x, v2.x}); - tri.max_x = std::max({v0.x, v1.x, v2.x}); - tri.min_z = std::min({v0.z, v1.z, v2.z}); - tri.max_z = std::max({v0.z, v1.z, v2.z}); - tri.normal = normal; - tri.d = normal.dot(v0); - out_heights.push_back(tri); - } - } -} - -int add_attribute_data(const std::string &path, const Transform3D &place, - Node3D *root, MeshInstance3D *mesh, - std::vector *out_heights = nullptr) { - fmt::AttributeData data; - std::string error; - if (!fmt::parse_attribute_data_file(path, data, &error)) { - UtilityFunctions::push_warning(String("[mdatr] load failed: ") + path.c_str() + - " (" + error.c_str() + ")"); - return 0; - } - if (out_heights) { - extract_height_triangles(data, place, *out_heights); - } - StaticBody3D *body = memnew(StaticBody3D); - body->set_name(String("AttributeBody_") + String(path.c_str()).get_file().get_basename()); - body->set_collision_layer(2); - body->set_collision_mask(0); - body->set_transform(place); - int shapes = 0; - for (const auto &collision : data.collisions) - if (add_attribute_collision(collision, body)) - ++shapes; - for (const auto &height : data.heights) { - if (height.vertices.size() < 3) - continue; - PackedVector3Array faces; - faces.resize((int)height.vertices.size()); - for (int i = 0; i < (int)height.vertices.size(); ++i) - faces[i] = attribute_position(height.vertices[(size_t)i]); - Ref concave; - concave.instantiate(); - concave->set_faces(faces); - CollisionShape3D *node = memnew(CollisionShape3D); - node->set_name(String(height.name.c_str())); - node->set_shape(concave); - body->add_child(node); - ++shapes; - } - if (shapes == 0) { - body->queue_free(); - return 0; - } - body->set_meta("occ_mesh", mesh); - root->add_child(body); - return shapes; -} - -void load_attribute_heights(const std::string &path, const Transform3D &place, - std::vector &out_heights) { - fmt::AttributeData data; - std::string error; - if (fmt::parse_attribute_data_file(path, data, &error)) { - extract_height_triangles(data, place, out_heights); - } -} - -} // namespace - -Metin2World::Metin2World() {} -Metin2World::~Metin2World() {} - -void Metin2World::_bind_methods() { - ClassDB::bind_method(D_METHOD("set_assets_root", "p"), &Metin2World::set_assets_root); - ClassDB::bind_method(D_METHOD("get_assets_root"), &Metin2World::get_assets_root); - ClassDB::bind_method(D_METHOD("set_map_path", "p"), &Metin2World::set_map_path); - ClassDB::bind_method(D_METHOD("get_map_path"), &Metin2World::get_map_path); - ClassDB::bind_method(D_METHOD("set_load_radius_tiles", "r"), &Metin2World::set_load_radius_tiles); - ClassDB::bind_method(D_METHOD("get_load_radius_tiles"), &Metin2World::get_load_radius_tiles); - ClassDB::bind_method(D_METHOD("set_focus_tile", "t"), &Metin2World::set_focus_tile); - ClassDB::bind_method(D_METHOD("set_initial_focus_cm", "position"), &Metin2World::set_initial_focus_cm); - ClassDB::bind_method(D_METHOD("get_focus_tile"), &Metin2World::get_focus_tile); - ClassDB::bind_method(D_METHOD("set_auto_load", "v"), &Metin2World::set_auto_load); - ClassDB::bind_method(D_METHOD("get_auto_load"), &Metin2World::get_auto_load); - ClassDB::bind_method(D_METHOD("set_splat_enabled", "v"), &Metin2World::set_splat_enabled); - ClassDB::bind_method(D_METHOD("get_splat_enabled"), &Metin2World::get_splat_enabled); - ClassDB::bind_method(D_METHOD("set_terrain_patches", "n"), &Metin2World::set_terrain_patches); - ClassDB::bind_method(D_METHOD("get_terrain_patches"), &Metin2World::get_terrain_patches); - ClassDB::bind_method(D_METHOD("set_objects_enabled", "v"), &Metin2World::set_objects_enabled); - ClassDB::bind_method(D_METHOD("get_objects_enabled"), &Metin2World::get_objects_enabled); - ClassDB::bind_method(D_METHOD("set_env_enabled", "v"), &Metin2World::set_env_enabled); - ClassDB::bind_method(D_METHOD("get_env_enabled"), &Metin2World::get_env_enabled); - ClassDB::bind_method(D_METHOD("set_water_enabled", "v"), &Metin2World::set_water_enabled); - ClassDB::bind_method(D_METHOD("set_collision_enabled", "v"), &Metin2World::set_collision_enabled); - ClassDB::bind_method(D_METHOD("get_water_enabled"), &Metin2World::get_water_enabled); - ClassDB::bind_method(D_METHOD("set_tree_shadows", "v"), &Metin2World::set_tree_shadows); - ClassDB::bind_method(D_METHOD("get_tree_shadows"), &Metin2World::get_tree_shadows); - ClassDB::bind_method(D_METHOD("set_static_shadows", "v"), &Metin2World::set_static_shadows); - ClassDB::bind_method(D_METHOD("get_static_shadows"), &Metin2World::get_static_shadows); - ClassDB::bind_method(D_METHOD("set_stream_budget", "v"), &Metin2World::set_stream_budget); - ClassDB::bind_method(D_METHOD("get_stream_budget"), &Metin2World::get_stream_budget); - ClassDB::bind_method(D_METHOD("load_map"), &Metin2World::load_map); - ClassDB::bind_method(D_METHOD("unload_map"), &Metin2World::unload_map); - ClassDB::bind_method(D_METHOD("set_focus_position", "gx_m", "gz_m"), - &Metin2World::set_focus_position); - ClassDB::bind_method(D_METHOD("get_perf"), &Metin2World::get_perf); - ClassDB::bind_method(D_METHOD("get_map_base_cm"), &Metin2World::get_map_base_cm); - ClassDB::bind_method(D_METHOD("get_map_size_tiles"), &Metin2World::get_map_size_tiles); - ClassDB::bind_method(D_METHOD("sample_height", "gx_m", "gz_m"), &Metin2World::sample_height); - ClassDB::bind_method(D_METHOD("sample_attribute", "gx_m", "gz_m"), &Metin2World::sample_attribute); - ClassDB::bind_method(D_METHOD("is_blocked", "gx_m", "gz_m"), &Metin2World::is_blocked); - ClassDB::bind_method(D_METHOD("get_fishing_rotation", "gx_m", "gz_m", "heading_deg"), - &Metin2World::get_fishing_rotation); - ClassDB::bind_method(D_METHOD("can_fishing_position", "gx_m", "gz_m", "heading_deg"), - &Metin2World::can_fishing_position); - ClassDB::bind_method(D_METHOD("load_dds", "path"), &Metin2World::load_dds); - ClassDB::bind_method(D_METHOD("chunk_dir", "tx", "ty"), &Metin2World::chunk_dir); - ClassDB::bind_method(D_METHOD("get_load_report"), &Metin2World::get_load_report); - ClassDB::bind_method(D_METHOD("get_ambience_sources"), &Metin2World::get_ambience_sources); - ClassDB::bind_method(D_METHOD("bake_asset_index", "out_path"), &Metin2World::bake_asset_index); - - ADD_PROPERTY(PropertyInfo(Variant::STRING, "assets_root", PROPERTY_HINT_GLOBAL_DIR), - "set_assets_root", "get_assets_root"); - ADD_PROPERTY(PropertyInfo(Variant::STRING, "map_path"), "set_map_path", "get_map_path"); - ADD_PROPERTY(PropertyInfo(Variant::INT, "load_radius_tiles"), - "set_load_radius_tiles", "get_load_radius_tiles"); - ADD_PROPERTY(PropertyInfo(Variant::VECTOR2I, "focus_tile"), - "set_focus_tile", "get_focus_tile"); - ADD_PROPERTY(PropertyInfo(Variant::BOOL, "auto_load"), "set_auto_load", "get_auto_load"); - ADD_PROPERTY(PropertyInfo(Variant::BOOL, "splat_enabled"), - "set_splat_enabled", "get_splat_enabled"); - ADD_PROPERTY(PropertyInfo(Variant::INT, "terrain_patches"), "set_terrain_patches", "get_terrain_patches"); - ADD_PROPERTY(PropertyInfo(Variant::BOOL, "objects_enabled"), - "set_objects_enabled", "get_objects_enabled"); - ADD_PROPERTY(PropertyInfo(Variant::BOOL, "env_enabled"), - "set_env_enabled", "get_env_enabled"); - ADD_PROPERTY(PropertyInfo(Variant::BOOL, "water_enabled"), - "set_water_enabled", "get_water_enabled"); - ADD_PROPERTY(PropertyInfo(Variant::BOOL, "tree_shadows"), - "set_tree_shadows", "get_tree_shadows"); - ADD_PROPERTY(PropertyInfo(Variant::BOOL, "static_shadows"), - "set_static_shadows", "get_static_shadows"); - ADD_PROPERTY(PropertyInfo(Variant::INT, "stream_budget"), - "set_stream_budget", "get_stream_budget"); -} - -void Metin2World::set_assets_root(const String &p) { assets_root = p; } -void Metin2World::set_map_path(const String &p) { map_path = p; } -void Metin2World::set_focus_tile(Vector2i t) { focus_tx = t.x; focus_ty = t.y; } - -void Metin2World::_ready() { - set_process(true); // streaming 队列逐帧建 - if (auto_load && !assets_root.is_empty()) - load_map(); -} - -String Metin2World::map_dir() const { - String r = assets_root; - if (!r.ends_with("/")) - r += "/"; - return r + map_path; -} - -const Metin2World::Chunk *Metin2World::chunk_at(int tx, int ty) const { - for (auto &c : chunks) - if (c.tx == tx && c.ty == ty) - return &c; - return nullptr; -} - -bool Metin2World::build_chunk(int tx, int ty) { - const bool profile_map = std::getenv("MT_PROFILE_MAP") != nullptr; - const auto ticks = [] { return Time::get_singleton()->get_ticks_usec(); }; - const double profile_start = profile_map ? ticks() : 0; - const std::string dir = - std::string(map_dir().utf8().get_data()) + "/" + fmt::m2coord::tile_dir(tx, ty); - auto hm = std::make_shared(); - std::string err; - if (!fmt::load_height_map(dir + "/height.raw", *hm, &err)) { - last_error = String("tile ") + fmt::m2coord::tile_dir(tx, ty).c_str() + ": " + err.c_str(); - ++chunks_failed; - return false; - } - const double profile_height = profile_map ? ticks() : 0; - auto am = std::make_shared(); - if (!fmt::load_attr_map(dir + "/attr.atr", *am, &err)) - am.reset(); // 非致命:attr 缺失 -> 该区块无阻挡 - const double profile_attr = profile_map ? ticks() : 0; - - fmt::TerrainMesh tmesh; - fmt::build_terrain_mesh(*hm, tx, ty, setting.height_scale, tmesh); - const double profile_terrain = profile_map ? ticks() : 0; - - Ref mat; - { - Ref grey; - grey.instantiate(); - grey->set_albedo(Color(0.5f, 0.5f, 0.5f)); - mat = grey; - } - - bool splatted = false; - double profile_tile_ms = 0, profile_alpha_ms = 0, profile_material_ms = 0; - if (splat_ready && resolver) { - fmt::TileMap tile; - std::string e2; - const double tile_start = profile_map ? ticks() : 0; - if (fmt::load_tile_map(dir + "/tile.raw", tile, &e2)) { - if (profile_map) profile_tile_ms = (ticks() - tile_start) / 1000.0; - fmt::SplatSet ss; - const double alpha_start = profile_map ? ticks() : 0; - fmt::build_splat(tile, texture_set.runtime_count(), ss); - if (profile_map) profile_alpha_ms = (ticks() - alpha_start) / 1000.0; - if (!ss.layers.empty()) { - String smpath; - String sm = String(dir.c_str()) + "/shadowmap.dds"; - if (mtgodot::file_exists(sm)) - smpath = sm; - const double material_start = profile_map ? ticks() : 0; - Ref tm = build_chunk_terrain_material( - ss, texture_set, *resolver, smpath); - if (profile_map) profile_material_ms = (ticks() - material_start) / 1000.0; - if (tm.is_valid()) { - mat = tm; - splatted = true; - } - } - } - } - if (splatted) - ++chunks_splatted; - const double profile_splat = profile_map ? ticks() : 0; - - // 该区块的场景根 —— terrain / water / 对象 / 树都挂它下面,卸载 = free 它 - Node3D *croot = memnew(Node3D); - croot->set_name(String("Chunk_") + fmt::m2coord::tile_dir(tx, ty).c_str()); - add_child(croot); - - // §3.5: 拆成 N×N patch,逐 patch MeshInstance —— Godot 自动逐 patch 视锥剔除, - // 远处 patch 用 visibility_range 整片剔除。terrain_patches=1 = 旧行为(整区块一 mesh)。 - const int QN = fmt::TerrainMesh::QUADS_XY; // 128 - const int Pn = (terrain_patches >= 1 && QN % terrain_patches == 0) ? terrain_patches : 1; - const int P = QN / Pn; // 每 patch 边上的 quad 数 - const int VN = fmt::TerrainMesh::VERTS_XY; // 129 - const int pw = P + 1; // 每 patch 边上的顶点数 - - for (int pj = 0; pj < Pn; ++pj) { - for (int pi = 0; pi < Pn; ++pi) { - const int i0 = pi * P, j0 = pj * P; - PackedVector3Array pv, pn; - PackedVector2Array pu; - pv.resize(pw * pw); - pn.resize(pw * pw); - pu.resize(pw * pw); - for (int lj = 0; lj < pw; ++lj) { - for (int li = 0; li < pw; ++li) { - const int src = (j0 + lj) * VN + (i0 + li); - const int dst = lj * pw + li; - pv[dst] = Vector3(tmesh.positions[src * 3 + 0], tmesh.positions[src * 3 + 1], - tmesh.positions[src * 3 + 2]); - pn[dst] = Vector3(tmesh.normals[src * 3 + 0], tmesh.normals[src * 3 + 1], - tmesh.normals[src * 3 + 2]); - pu[dst] = Vector2(tmesh.uvs[src * 2 + 0], tmesh.uvs[src * 2 + 1]); - } - } - PackedInt32Array pidx; - pidx.resize(P * P * 6); - int k = 0; - for (int lj = 0; lj < P; ++lj) { - for (int li = 0; li < P; ++li) { - const int TL = lj * pw + li, TR = TL + 1, BL = TL + pw, BR = BL + 1; - pidx[k++] = TL; pidx[k++] = BR; pidx[k++] = BL; - pidx[k++] = TL; pidx[k++] = TR; pidx[k++] = BR; - } - } - Array pa; - pa.resize(Mesh::ARRAY_MAX); - pa[Mesh::ARRAY_VERTEX] = pv; - pa[Mesh::ARRAY_NORMAL] = pn; - pa[Mesh::ARRAY_TEX_UV] = pu; - pa[Mesh::ARRAY_INDEX] = pidx; - Ref pmesh; - pmesh.instantiate(); - pmesh->add_surface_from_arrays(Mesh::PRIMITIVE_TRIANGLES, pa); - pmesh->surface_set_material(0, mat); - - MeshInstance3D *pm = memnew(MeshInstance3D); - pm->set_name(Pn > 1 ? vformat("Terrain_%d_%d", pi, pj) : String("Terrain")); - pm->set_layer_mask(1u << 0); // Background light visibility layer. - pm->set_mesh(pmesh); - if (Pn > 1 && terrain_patch_view > 0.0f) { - // 硬剔除(无半透明淡出),避免远景地形变透明 - pm->set_visibility_range_end(terrain_patch_view); - pm->set_visibility_range_fade_mode(GeometryInstance3D::VISIBILITY_RANGE_FADE_DISABLED); - } - croot->add_child(pm); - } - } - ++chunks_built; - const double profile_godot_mesh = profile_map ? ticks() : 0; - - // 地形碰撞(W1 item 6):HeightMapShape3D,129×129,格距 = CELL_M(缩放承载) - if (collision_enabled) { - const int N = fmt::TerrainMesh::VERTS_XY; // 129 - PackedFloat32Array hd; - hd.resize(N * N); - for (int j = 0; j < N; ++j) - for (int i = 0; i < N; ++i) - hd[j * N + i] = tmesh.positions[(j * N + i) * 3 + 1]; // Godot Y - Ref hs; - hs.instantiate(); - hs->set_map_width(N); - hs->set_map_depth(N); - hs->set_map_data(hd); - StaticBody3D *sb = memnew(StaticBody3D); - sb->set_name("TerrainBody"); - CollisionShape3D *cs = memnew(CollisionShape3D); - cs->set_shape(hs); - sb->add_child(cs); - croot->add_child(sb); - const double CM = fmt::m2coord::CELLSCALE * fmt::m2coord::CM_TO_M; // 2m - const double X0 = double(tx) * fmt::m2coord::CHUNK_CM * fmt::m2coord::CM_TO_M; - const double Z0 = double(ty) * fmt::m2coord::CHUNK_CM * fmt::m2coord::CM_TO_M; - // HeightMapShape 以中心为原点、格距 1 -> 缩放到 CM,平移到区块中心 - Transform3D t; - t.basis.scale(Vector3(CM, 1, CM)); - t.origin = Vector3(X0 + (N - 1) * 0.5 * CM, 0, Z0 + (N - 1) * 0.5 * CM); - sb->set_transform(t); - } - - // 水面(water.wtr) - if (water_enabled) { - fmt::WaterMap wm; - std::string we; - if (fmt::load_water_map(dir + "/water.wtr", wm, &we) && wm.layer_count > 0 && resolver) { - auto pieces = build_chunk_water(wm, *hm, tx, ty, setting.height_scale, *resolver); - for (auto &p : pieces) { - if (!p.mesh.is_valid()) - continue; - MeshInstance3D *w = memnew(MeshInstance3D); - w->set_name("Water"); - w->set_layer_mask(1u << 0); - w->set_mesh(p.mesh); - croot->add_child(w); - ++water_pieces; - } - } - } - - Chunk ck; - ck.tx = tx; - ck.ty = ty; - ck.hm = hm; - ck.am = am; - ck.root = croot; - if (objects_enabled && registry_ok && resolver) - place_chunk_objects(tx, ty, croot, ck.objects, ck.trees, ck.height_triangles); - if (registry_ok) - place_chunk_ambience(tx, ty); - objects_placed += ck.objects; - trees_placed += ck.trees; - chunks.push_back(std::move(ck)); - if (profile_map) { - const double profile_end = ticks(); - UtilityFunctions::print(vformat("MAP_PROFILE %d,%d height=%.3f attr=%.3f terrain_cpu=%.3f splat=%.3f tile=%.3f alpha=%.3f material=%.3f godot_mesh=%.3f rest=%.3f total=%.3f", - tx, ty, (profile_height - profile_start) / 1000.0, - (profile_attr - profile_height) / 1000.0, - (profile_terrain - profile_attr) / 1000.0, - (profile_splat - profile_terrain) / 1000.0, - profile_tile_ms, profile_alpha_ms, profile_material_ms, - (profile_godot_mesh - profile_splat) / 1000.0, - (profile_end - profile_godot_mesh) / 1000.0, - (profile_end - profile_start) / 1000.0)); - } - return true; -} - -void Metin2World::place_chunk_objects(int tx, int ty, Node3D *root, int &n_obj, int &n_tree, - std::vector &out_heights) { - n_obj = 0; - n_tree = 0; - const std::string mdir = std::string(map_dir().utf8().get_data()); - - // 树按 treefile 分组 -> 每组一个 MultiMeshInstance3D - struct TreeGroup { - std::vector xforms; - }; - std::map tree_groups; - - { - fmt::AreaData ad; - std::string e; - if (!fmt::parse_area_data_file( - mdir + "/" + fmt::m2coord::tile_dir(tx, ty) + "/areadata.txt", ad, &e)) - return; - - for (const fmt::AreaObject &o : ad.objects) { - const fmt::Property *p = registry.find(o.crc); - if (!p) { - ++objects_skipped; - continue; - } - - // areadata position = 地图全局 cm(W0 结论修正:不是区块本地!x 正/东,y 负/南)。 - const double mx = o.x; - const double my = o.y; - const double mz = o.z + o.height_bias; - fmt::m2coord::Vec3 g = fmt::m2coord::position_to_godot(mx, my, mz); - - if (p->type == fmt::PropertyType::Tree) { - // 原客户端 Area -> Forest::CreateInstance 在地图放置路径只设置位置; - // 不给每实例添加随机 yaw/scale。树种差异由 treefile 对应的共享 mesh 承载。 - std::string tf = p->get("treefile"); - if (tf.empty()) { - ++objects_skipped; - continue; - } - Basis b; - tree_groups[tf].xforms.push_back(Transform3D(b, Vector3(g.x, g.y, g.z))); - continue; - } - - std::string model; - if (p->type == fmt::PropertyType::Building) - model = p->get("buildingfile"); - else if (p->type == fmt::PropertyType::DungeonBlock) - model = p->get("dungeonblockfile"); - else { - ++objects_skipped; // Effect / Ambience 不影响 R1 画面 - continue; - } - if (model.empty()) { - ++objects_skipped; - continue; - } - std::string rp = resolver->resolve(model, nullptr); - if (rp.empty()) { - ++objects_missing_model; - continue; - } - Ref mesh = get_static_mesh(rp, *resolver, static_cache); - if (!mesh.is_valid()) { - ++objects_missing_model; - continue; - } - - // areadata yaw#pitch#roll 已共轭到 Godot 空间(roll = 朝向 -> 绕 Godot +Y)。 - fmt::m2coord::Mat3 r = fmt::m2coord::object_basis_godot(o.yaw, o.pitch, o.roll); - Basis place_basis( - Vector3(r.m[0], r.m[3], r.m[6]), - Vector3(r.m[1], r.m[4], r.m[7]), - Vector3(r.m[2], r.m[5], r.m[8])); - Transform3D place(place_basis, Vector3(g.x, g.y, g.z)); - // mesh 顶点是 gr2 原始 cm / Z-up -> 本地再套 make_conv(cm->m + Z-up->Y-up) - Transform3D xform = place * make_conv(0.01f, false); - - MeshInstance3D *mi = memnew(MeshInstance3D); - mi->set_name(String(p->name.c_str()) + "_" + String::num_uint64(o.crc)); - mi->set_layer_mask(1u << 0); // Background light visibility layer. - mi->set_mesh(mesh); - mi->set_transform(xform); - // ShadowFlag: 客户端把 isShadowFlag 物体丢进动态阴影贴图(= 我们的实时投影); - // 其余物体的阴影只存在于烘焙 shadowmap.dds。__static_shadows 可强制全部实时投。 - if (p->get("shadowflag") != "1" && !static_shadows) - mi->set_cast_shadows_setting(GeometryInstance3D::SHADOW_CASTING_SETTING_OFF); - // portal ids 先存进 meta(R1 不做室内裁剪) - if (!o.portal_ids.empty()) { - Array pids; - for (int pid : o.portal_ids) - pids.push_back(pid); - mi->set_meta("portal_ids", pids); - } - root->add_child(mi); - // §8.2/§8.3: 盒碰撞体(层 2 = 静态遮挡物)。相机用它做防穿 + 遮挡淡出。 - // 盒尺寸/中心直接算到「米 / Y-up」,body 只带 place(旋转+平移,无 conv), - // 避免把碰撞形状放在 0.01 缩放节点下(Godot 缩放 shape 不稳)。 - bool mdatr_built = false; - String md = String(rp.c_str()).get_basename() + ".mdatr"; - if (FileAccess::file_exists(md)) { - const std::string md_str(md.utf8().get_data()); - if (collision_enabled) { - const int built = add_attribute_data(md_str, place, root, mi, &out_heights); - mdatr_built = built > 0; - if (mdatr_built) - ++objects_mdatr_built; - } else { - load_attribute_heights(md_str, place, out_heights); - } - } - // A malformed/missing attribute resource still gets a conservative - // GR2 AABB fallback. A valid .mdatr owns the exact collision set. - if (collision_enabled && !mdatr_built) { - AABB ab = mesh->get_aabb(); // gr2 原始 cm / Z-up - if (ab.size.length() > 0.001f) { - Vector3 c_cm = ab.position + ab.size * 0.5f; // 中心 cm Z-up - StaticBody3D *body = memnew(StaticBody3D); - body->set_collision_layer(2); - body->set_collision_mask(0); - body->set_transform(place); // 世界旋转 + 平移 - CollisionShape3D *cs = memnew(CollisionShape3D); - Ref box; - box.instantiate(); - // Z-up cm -> Y-up m:(sx, sz, sy) * 0.01 - box->set_size(Vector3(ab.size.x, ab.size.z, ab.size.y) * 0.01f); - cs->set_shape(box); - cs->set_position(Vector3(c_cm.x, c_cm.z, -c_cm.y) * 0.01f); - body->add_child(cs); - body->set_meta("occ_mesh", mi); // §8.2 相机淡出用 - root->add_child(body); - } - } - ++n_obj; - } - } - - // Native extracted tree geometry, shared once per treefile across instances. - for (auto &kv : tree_groups) { - if (kv.second.xforms.empty()) - continue; - Ref tmesh = get_tree_mesh(kv.first, *resolver, 12.0f); - const bool native = String(tmesh->get_meta("tree_geometry", "proxy")) == "native_spt"; - (native ? native_trees_placed : proxy_trees_placed) += (int)kv.second.xforms.size(); - Ref mm; - mm.instantiate(); - mm->set_transform_format(MultiMesh::TRANSFORM_3D); - mm->set_mesh(tmesh); - mm->set_instance_count((int)kv.second.xforms.size()); - for (int i = 0; i < (int)kv.second.xforms.size(); ++i) - mm->set_instance_transform(i, kv.second.xforms[i]); - MultiMeshInstance3D *mmi = memnew(MultiMeshInstance3D); - mmi->set_name(String("Trees_") + String(kv.first.c_str()).get_file().get_basename()); - mmi->set_layer_mask(1u << 0); - mmi->set_multimesh(mm); - mmi->set_meta("tree_geometry", native ? "native_spt" : "proxy"); - // §3.3: 树影已烘焙进 shadowmap.dds;proxy 几何的实时投影形状也不对 -> 默认不投。 - mmi->set_cast_shadows_setting(tree_shadows ? GeometryInstance3D::SHADOW_CASTING_SETTING_ON - : GeometryInstance3D::SHADOW_CASTING_SETTING_OFF); - mmi->set_visibility_range_end(420.0f); // 远处树剔除(W8 打磨) - mmi->set_visibility_range_end_margin(60.0f); // 淡出过渡 - root->add_child(mmi); - n_tree += (int)kv.second.xforms.size(); - ++tree_species; - } -} - -void Metin2World::place_chunk_ambience(int tx, int ty) { - // Area ambience is a separate file in ClientVS22. The .pra property owns - // play type / interval / sound names; Audio owns the runtime instances. - const std::string mdir = std::string(map_dir().utf8().get_data()); - fmt::AreaAmbienceData ad; - std::string e; - const std::string base = mdir + "/" + fmt::m2coord::tile_dir(tx, ty) + "/"; - bool loaded = fmt::parse_area_ambience_file(base + "areaambiencedata.txt", ad, &e); - if (!loaded) - loaded = fmt::parse_area_ambience_file(base + "AreaAmbienceData.txt", ad, &e); - if (!loaded) - return; // 老地图可能没有环境声文件。 - - for (size_t i = 0; i < ad.objects.size(); ++i) { - const fmt::AreaAmbienceObject &o = ad.objects[i]; - const fmt::Property *p = registry.find(o.crc); - if (!p || p->type != fmt::PropertyType::Ambience) - continue; - AmbienceSource source; - source.tile_x = tx; - source.tile_y = ty; - source.object_index = (int)i; - const fmt::m2coord::Vec3 g = fmt::m2coord::position_to_godot(o.x, o.y, o.z); - source.position = Vector3((float)g.x, (float)g.y, (float)g.z); - source.range_cm = std::max(0, (int)std::lround(o.range)); - source.max_volume_area_percentage = (float)std::strtof( - p->get("maxvolumeareapercentage", "0").c_str(), nullptr); - source.play_interval = (float)std::strtof( - p->get("playinterval", "0").c_str(), nullptr); - source.play_interval_variation = (float)std::strtof( - p->get("playintervalvariation", "0").c_str(), nullptr); - source.play_type = p->get("playtype", "LOOP").c_str(); - const std::string sounds = p->get("ambiencesoundvector"); - if (!sounds.empty()) - source.sounds.push_back(sounds); - if (!source.sounds.empty() && source.range_cm > 0) - ambience_sources.push_back(std::move(source)); - } -} - -bool Metin2World::load_map() { - unload_map(); - - std::string err; - if (!fmt::parse_map_setting_file( - std::string(map_dir().utf8().get_data()) + "/setting.txt", setting, &err)) { - last_error = String("setting.txt: ") + err.c_str(); - setting_ok = false; - UtilityFunctions::push_error(String("[Metin2World] ") + last_error); - return false; - } - setting_ok = true; - if (load_radius >= 0 && initial_focus_cm != Vector2()) { - focus_tx = (int)std::floor((initial_focus_cm.x - setting.base_position_x) / - fmt::m2coord::CHUNK_CM); - focus_ty = (int)std::floor((initial_focus_cm.y - setting.base_position_y) / - fmt::m2coord::CHUNK_CM); - } - - // AssetResolver 是地图对象、环境声属性和 splat 共用的资源索引。 - // 即使关闭 splat,也要保留这条链路,避免环境声依赖渲染开关。 - splat_ready = false; - const std::string root = std::string(assets_root.utf8().get_data()); - resolver = std::make_shared(); - std::string re; - // asset_index.txt 存在就装载(PCK/移动端必走),否则扫盘(桌面开发)。 - if (!resolver->build_or_load(root, fmt::AssetResolver::default_priority(), &re)) { - UtilityFunctions::push_warning(String("[Metin2World] AssetResolver: ") + re.c_str()); - resolver.reset(); - } - if (splat_enabled && resolver) { - std::string ts_rel = setting.texture_set; - for (char &c : ts_rel) { - if (c == '\\') - c = '/'; - c = (char)std::tolower((unsigned char)c); - } - // Setting.txt may already name the textureset/ directory. The pack - // virtual root ends in '/', while a loose asset root does not. - if (ts_rel.rfind("textureset/", 0) == 0) - ts_rel.erase(0, sizeof("textureset/") - 1); - std::string tse; - const std::string texture_path = root == "pack://" - ? root + "textureset/" + ts_rel : root + "/textureset/" + ts_rel; - if (fmt::parse_texture_set_file(texture_path, texture_set, &tse)) - splat_ready = true; - else - UtilityFunctions::push_warning(String("[Metin2World] TextureSet: ") + tse.c_str()); - } - - // A 40250 pack is a virtual filesystem, not a directory to scan. The terrain - // and its textures resolve by pack path; property objects are a later port. - registry_ok = false; - if (root != "pack://") { - // resolver 已建:用它的文件清单,避免再 std::filesystem 扫盘(PCK 里扫不了)。 - bool ok = resolver - ? registry.scan_list(root, resolver->all_rel(), &re) - : registry.scan(root + "/Property", &re); - if (ok) - registry_ok = true; - else - UtilityFunctions::push_warning(String("[Metin2World] Property scan: ") + re.c_str()); - } - - const double t0 = Time::get_singleton()->get_ticks_usec(); - if (load_radius < 0) { - // 非流式:一次全建 - for (int tx = 0; tx < setting.map_size_x; ++tx) - for (int ty = 0; ty < setting.map_size_y; ++ty) - build_chunk(tx, ty); - } else { - // 流式:把 focus 半径内的区块入队,_process 逐帧建 - stream_update(); - // 首帧同步建完队列,避免第一帧空场景 - while (!stream_queue.empty()) { - auto [tx, ty] = stream_queue.front(); - stream_queue.erase(stream_queue.begin()); - build_chunk(tx, ty); - } - } - - // .msenv -> 光照 / 天空 / 雾 / 色调 - env_ok = false; - if (env_enabled && !setting.environment.empty()) { - std::string vp = std::string("d:/ymir work/environment/") + setting.environment; - std::string rp = resolver ? resolver->resolve(vp, nullptr) : std::string(); - if (rp.empty()) - rp = std::string(assets_root.utf8().get_data()) + - "/ETC/ymir work/environment/" + setting.environment; - std::string ee; - if (fmt::parse_environment_file(rp, env, &ee)) { - apply_environment(env, this, resolver.get()); - env_ok = true; - } else { - UtilityFunctions::push_warning(String("[Metin2World] .msenv: ") + ee.c_str()); - } - } - build_ms = (Time::get_singleton()->get_ticks_usec() - t0) / 1000.0; - - UtilityFunctions::print(String("[Metin2World] ") + map_path + " loaded: " + - String::num_int64(chunks_built) + "/" + - String::num_int64(setting.map_size_x * setting.map_size_y) + " chunks, " + - String::num_int64(chunks_splatted) + " splatted, " + - String::num_int64(objects_placed) + " objects (" + - String::num_int64(objects_missing_model) + " missing model), " + - String::num_int64(trees_placed) + " trees/" + String::num_int64(tree_species) + " spp, " + - String::num(build_ms, 1) + " ms"); - return chunks_failed == 0; -} - -void Metin2World::unload_chunk(int idx) { - if (idx < 0 || idx >= (int)chunks.size()) - return; - Chunk &c = chunks[idx]; - objects_placed -= c.objects; - trees_placed -= c.trees; - if (chunks_built > 0) - --chunks_built; - if (c.root) - c.root->queue_free(); - ambience_sources.erase(std::remove_if(ambience_sources.begin(), ambience_sources.end(), - [&c](const AmbienceSource &s) { return s.tile_x == c.tx && s.tile_y == c.ty; }), - ambience_sources.end()); - chunks.erase(chunks.begin() + idx); -} - -void Metin2World::stream_update() { - if (!setting_ok || load_radius < 0) - return; - // 卸载半径外 - for (int i = (int)chunks.size() - 1; i >= 0; --i) { - if (std::abs(chunks[i].tx - focus_tx) > load_radius || - std::abs(chunks[i].ty - focus_ty) > load_radius) - unload_chunk(i); - } - // 入队半径内且未加载 / 未在队列的 - stream_queue.clear(); - for (int dx = -load_radius; dx <= load_radius; ++dx) - for (int dy = -load_radius; dy <= load_radius; ++dy) { - int tx = focus_tx + dx, ty = focus_ty + dy; - if (tx < 0 || ty < 0 || tx >= setting.map_size_x || ty >= setting.map_size_y) - continue; - if (!chunk_at(tx, ty)) - stream_queue.push_back({tx, ty}); - } -} - -void Metin2World::set_focus_position(double gx_m, double gz_m) { - const double mx = gx_m / fmt::m2coord::CM_TO_M; - const double my_abs = gz_m / fmt::m2coord::CM_TO_M; - int tx = std::max(0, std::min(setting.map_size_x - 1, int(mx / fmt::m2coord::CHUNK_CM))); - int ty = std::max(0, std::min(setting.map_size_y - 1, int(my_abs / fmt::m2coord::CHUNK_CM))); - if (tx == focus_tx && ty == focus_ty) - return; - focus_tx = tx; - focus_ty = ty; - if (load_radius >= 0) - stream_update(); -} - -void Metin2World::_process(double) { - update_water_animation(); - for (int n = 0; n < stream_budget && !stream_queue.empty(); ++n) { - auto [tx, ty] = stream_queue.front(); - stream_queue.erase(stream_queue.begin()); - build_chunk(tx, ty); - } -} - -void Metin2World::unload_map() { - while (!chunks.empty()) - unload_chunk((int)chunks.size() - 1); - stream_queue.clear(); - ambience_sources.clear(); - if (objects_root) { - objects_root->queue_free(); - objects_root = nullptr; - } - for (const char *nm : {"Sun", "CharacterLight", "WorldEnv"}) - if (Node *n = get_node_or_null(NodePath(nm))) - n->queue_free(); - water_pieces = 0; - env_ok = false; - env = fmt::Environment{}; - static_cache = StaticMeshCache{}; - registry = fmt::PropertyRegistry{}; - chunks_built = chunks_failed = chunks_splatted = 0; - objects_placed = objects_skipped = objects_missing_model = 0; - trees_placed = tree_species = 0; - native_trees_placed = proxy_trees_placed = 0; - objects_mdatr_built = 0; - build_ms = 0; - splat_ready = registry_ok = false; - last_error = ""; -} - -Vector2 Metin2World::get_map_base_cm() const { - return Vector2((float)setting.base_position_x, (float)setting.base_position_y); -} -Vector2i Metin2World::get_map_size_tiles() const { - return Vector2i(setting.map_size_x, setting.map_size_y); -} - -double Metin2World::sample_height(double gx_m, double gz_m) const { - if (!setting_ok) - return 0.0; - // Godot 米 -> Metin2 全局厘米。position_to_godot: gx = mx*0.01, gz = -my*0.01 - const double mx_cm = gx_m / fmt::m2coord::CM_TO_M; - const double my_abs_cm = gz_m / fmt::m2coord::CM_TO_M; // = -my; 已是正的 "南向距离" - const int tx = int(mx_cm / fmt::m2coord::CHUNK_CM); - const int ty = int(my_abs_cm / fmt::m2coord::CHUNK_CM); - const Chunk *c = chunk_at(tx, ty); - double terrain_h = 0.0; - if (c && c->hm) { - const double lx = mx_cm - double(tx) * fmt::m2coord::CHUNK_CM; - const double ly = my_abs_cm - double(ty) * fmt::m2coord::CHUNK_CM; - terrain_h = fmt::terrain_height_at(*c->hm, lx, ly, setting.height_scale) * fmt::m2coord::CM_TO_M; - } - - // 40250 CMapOutdoor::GetHeight(fx, fy): - // Check placed objects' height data (bridges, stairs, platforms from .mdatr). - // If object height > terrain height, return fMAX(fObjectHeight, fTerrainHeight). - float obj_h = -25000.0f; - bool obj_found = false; - - const float fgx = float(gx_m); - const float fgz = float(gz_m); - - for (int dtx = -1; dtx <= 1; ++dtx) { - int ntx = tx + dtx; - if (ntx < 0 || ntx >= setting.map_size_x) - continue; - for (int dty = -1; dty <= 1; ++dty) { - int nty = ty + dty; - if (nty < 0 || nty >= setting.map_size_y) - continue; - const Chunk *nc = chunk_at(ntx, nty); - if (!nc || nc->height_triangles.empty()) - continue; - for (const auto &tri : nc->height_triangles) { - if (fgx < tri.min_x || fgx > tri.max_x || - fgz < tri.min_z || fgz > tri.max_z) - continue; - if (is_in_triangle_2d(tri.v0.x, tri.v0.z, tri.v1.x, tri.v1.z, tri.v2.x, tri.v2.z, fgx, fgz)) { - float h = (tri.d - tri.normal.x * fgx - tri.normal.z * fgz) / tri.normal.y; - if (!obj_found || h > obj_h) { - obj_h = h; - obj_found = true; - } - } - } - } - } - - if (obj_found && (c == nullptr || !c->hm || obj_h > terrain_h)) - return double(obj_h); - - return terrain_h; -} - -Ref Metin2World::load_dds(const String &path) const { - mtgodot::Image d = mtgodot::dds_from_file(path); - if (!d.ok()) - return Ref(); - PackedByteArray b; - b.resize((int64_t)d.rgba.size()); - for (size_t i = 0; i < d.rgba.size(); ++i) - b[(int64_t)i] = d.rgba[i]; - return godot::Image::create_from_data(d.w, d.h, false, godot::Image::FORMAT_RGBA8, b); -} - -String Metin2World::chunk_dir(int tile_x, int tile_y) const { - return map_dir() + "/" + fmt::m2coord::tile_dir(tile_x, tile_y).c_str(); -} - -bool Metin2World::bake_asset_index(const String &out_path) { - const std::string root(assets_root.utf8().get_data()); - fmt::AssetResolver r; - std::string err; - if (!r.build(root, fmt::AssetResolver::default_priority(), &err)) { - UtilityFunctions::push_error(String("[Metin2World] bake_asset_index build: ") + err.c_str()); - return false; - } - const std::string idx = r.save_index(); - Ref f = FileAccess::open(out_path, FileAccess::WRITE); - if (f.is_null()) { - UtilityFunctions::push_error(String("[Metin2World] bake_asset_index: cannot write ") + out_path); - return false; - } - f->store_buffer(reinterpret_cast(idx.data()), (int64_t)idx.size()); - f->close(); - UtilityFunctions::print(String("[Metin2World] asset_index: ") + - String::num_int64((int64_t)r.files_indexed) + " files -> " + out_path); - return true; -} - -int Metin2World::sample_attribute(double gx_m, double gz_m) const { - if (!setting_ok) - return 0; - const double mx_cm = gx_m / fmt::m2coord::CM_TO_M; - const double my_abs_cm = gz_m / fmt::m2coord::CM_TO_M; - const int tx = int(mx_cm / fmt::m2coord::CHUNK_CM); - const int ty = int(my_abs_cm / fmt::m2coord::CHUNK_CM); - const Chunk *c = chunk_at(tx, ty); - if (!c || !c->am) - return 0; - // ATTRMAP 256x256 / 区块 25600cm -> 100cm/texel - const double lx = mx_cm - double(tx) * fmt::m2coord::CHUNK_CM; - const double ly = my_abs_cm - double(ty) * fmt::m2coord::CHUNK_CM; - int ax = int(lx / 100.0), ay = int(ly / 100.0); - if (ax < 0 || ay < 0 || ax >= fmt::ATTRMAP_XY || ay >= fmt::ATTRMAP_XY) - return 0; - return c->am->data[size_t(ay) * fmt::ATTRMAP_XY + ax]; -} - -double Metin2World::get_fishing_rotation(double gx_m, double gz_m, double heading_deg) const { - // InstanceBaseMotion.cpp uses c_fFishingDistance=600.0f and - // ELPlainCoord_GetRotatedPixelPosition (sin(rot), cos(rot)). The POC - // world frame is the MapCoord frame, where heading -> world yaw is - // heading + 90 degrees; using the same conversion keeps the probe in - // the coordinate frame used by player_controller / MapCoord. - constexpr double DISTANCE_M = 6.0; - constexpr double WATER_ATTRIBUTE = 1 << 1; // PRTerrainLib::ATTRIBUTE_WATER - constexpr double DEG_TO_RAD = 3.14159265358979323846 / 180.0; - auto normalized = [](double d) { - d = std::fmod(d, 360.0); - return d < 0.0 ? d + 360.0 : d; - }; - auto water_at = [&](double candidate_heading) { - const double yaw = (candidate_heading + 90.0) * DEG_TO_RAD; - const double x = gx_m + std::sin(yaw) * DISTANCE_M; - const double z = gz_m + std::cos(yaw) * DISTANCE_M; - return (sample_attribute(x, z) & int(WATER_ATTRIBUTE)) != 0; - }; - - for (int step = 0; step <= 18; ++step) { - const double offset = double(step * 10); - const double right = heading_deg + offset; - if (water_at(right)) - return normalized(right); - const double left = heading_deg - offset; - if (water_at(left)) - return normalized(left); - } - return -1.0; -} - -bool Metin2World::can_fishing_position(double gx_m, double gz_m, double heading_deg) const { - return get_fishing_rotation(gx_m, gz_m, heading_deg) >= 0.0; -} - -Dictionary Metin2World::get_load_report() const { - Dictionary d; - d["map_path"] = map_path; - d["setting_ok"] = setting_ok; - d["map_size_x"] = setting.map_size_x; - d["map_size_y"] = setting.map_size_y; - d["cell_scale"] = setting.cell_scale; - d["height_scale"] = setting.height_scale; - d["chunks_built"] = chunks_built; - d["chunks_failed"] = chunks_failed; - d["chunks_splatted"] = chunks_splatted; - d["water_pieces"] = water_pieces; - d["splat_ready"] = splat_ready; - d["registry_ok"] = registry_ok; - d["registry_crcs"] = (int)registry.by_crc.size(); - d["env_ok"] = env_ok; - d["character_light_enabled"] = env.dir_light.ch_enable; - d["character_light_direction"] = Vector3(env.dir_light.direction[0], - env.dir_light.direction[1], env.dir_light.direction[2]); - d["character_light_diffuse"] = Color(env.dir_light.ch_diffuse[0], - env.dir_light.ch_diffuse[1], env.dir_light.ch_diffuse[2], env.dir_light.ch_diffuse[3]); - d["character_light_ambient"] = Color(env.dir_light.ch_ambient[0], - env.dir_light.ch_ambient[1], env.dir_light.ch_ambient[2], env.dir_light.ch_ambient[3]); - d["fog_level"] = env.fog.fog_level; - d["objects_placed"] = objects_placed; - d["objects_skipped"] = objects_skipped; - d["objects_missing_model"] = objects_missing_model; - d["ambience_sources"] = (int)ambience_sources.size(); - d["trees_placed"] = trees_placed; - d["tree_species"] = tree_species; - d["native_trees_placed"] = native_trees_placed; - d["proxy_trees_placed"] = proxy_trees_placed; - d["objects_mdatr_built"] = objects_mdatr_built; // 使用 .mdatr 精确碰撞的对象数 - d["static_meshes"] = static_cache.loaded; - d["build_ms"] = build_ms; - d["resident_chunks"] = (int)chunks.size(); - d["stream_queue"] = (int)stream_queue.size(); - d["load_radius_tiles"] = load_radius; - d["last_error"] = last_error; - return d; -} - -Array Metin2World::get_ambience_sources() const { - Array out; - for (const AmbienceSource &s : ambience_sources) { - Dictionary d; - d["key"] = String::num_int64(s.tile_x) + ":" + String::num_int64(s.tile_y) + ":" + - String::num_int64(s.object_index); - d["position"] = s.position; - d["range_cm"] = s.range_cm; - d["max_volume_area_percentage"] = s.max_volume_area_percentage; - d["play_interval"] = s.play_interval; - d["play_interval_variation"] = s.play_interval_variation; - d["play_type"] = s.play_type; - Array sounds; - for (const std::string &sound : s.sounds) - sounds.push_back(String(sound.c_str())); - d["sounds"] = sounds; - out.push_back(d); - } - return out; -} - -Dictionary Metin2World::get_perf() const { - Dictionary d; - Performance *pf = Performance::get_singleton(); - d["fps"] = pf->get_monitor(Performance::TIME_FPS); - d["process_ms"] = pf->get_monitor(Performance::TIME_PROCESS) * 1000.0; - d["frame_ms"] = pf->get_monitor(Performance::TIME_PROCESS) * 1000.0 + - pf->get_monitor(Performance::TIME_PHYSICS_PROCESS) * 1000.0; - d["draw_calls"] = pf->get_monitor(Performance::RENDER_TOTAL_DRAW_CALLS_IN_FRAME); - d["primitives"] = pf->get_monitor(Performance::RENDER_TOTAL_PRIMITIVES_IN_FRAME); - d["video_mem_mb"] = pf->get_monitor(Performance::RENDER_VIDEO_MEM_USED) / (1024.0 * 1024.0); - d["tex_mem_mb"] = pf->get_monitor(Performance::RENDER_TEXTURE_MEM_USED) / (1024.0 * 1024.0); - d["objects_3d"] = pf->get_monitor(Performance::RENDER_TOTAL_OBJECTS_IN_FRAME); - d["nodes"] = pf->get_monitor(Performance::OBJECT_NODE_COUNT); - d["resident_chunks"] = (int)chunks.size(); - d["stream_queue"] = (int)stream_queue.size(); - return d; -} - -} // namespace mtgodot diff --git a/extension/src/metin2_world.h b/extension/src/metin2_world.h deleted file mode 100644 index 24691738..00000000 --- a/extension/src/metin2_world.h +++ /dev/null @@ -1,198 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include - -#include -#include -#include - -#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 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 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 hm; - std::shared_ptr am; - godot::Node3D *root = nullptr; // 该区块的全部场景节点(terrain + water + 对象 + 树) - int objects = 0, trees = 0; - std::vector 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 sounds; - }; - std::vector chunks; - std::vector> stream_queue; // 待建区块 - std::vector 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 &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 diff --git a/extension/src/net/m2_client.cpp b/extension/src/net/m2_client.cpp deleted file mode 100644 index 7f375b1b..00000000 --- a/extension/src/net/m2_client.cpp +++ /dev/null @@ -1,4650 +0,0 @@ -#include "m2_client.h" - -#include "auth_client.h" -#include "classic/classic_mark_client.h" -#include "classic/classic_session.h" -#include "game_client.h" -#include "item_action_rules.h" -#include "mark_client.h" -#include "net_bounds.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include - -#include -#include - -using namespace godot; - -namespace mtgodot { - -namespace { - -bool valid_angle(double degrees) { - return std::isfinite(degrees); -} - -bool valid_item_pos(int window, int cell) { - return mtnet::bounds::item_cell(window, cell); -} - -bool read_int(const Dictionary &dict, const char *key, int64_t &value, int64_t fallback) { - const Variant raw = dict.get(key, fallback); - if (raw.get_type() != Variant::INT) { - return false; - } - value = static_cast(raw); - return true; -} - -} // namespace - -M2Client::M2Client() { - set_process(true); -} -M2Client::~M2Client() = default; - -void M2Client::set_item_proto(Object *proto) { - item_proto_id = proto ? proto->get_instance_id() : ObjectID(); - item_proto_ready = false; - if (proto) { - const Variant count = proto->call("item_count"); - item_proto_ready = count.get_type() == Variant::INT && (int)count > 0; - } - item_validator_world = nullptr; - apply_item_proto_validator(); -} - -void M2Client::_bind_methods() { - ClassDB::bind_method( - D_METHOD("connect_to_server", "auth_host", "auth_port", "game_host", "game_port", "id", "pw"), - &M2Client::connect_to_server); - ClassDB::bind_method( - D_METHOD("retry_login", "auth_host", "auth_port", "game_host", "game_port", "id", "pw"), - &M2Client::retry_login); - ClassDB::bind_method(D_METHOD("disconnect_from_server"), &M2Client::disconnect_from_server); - ClassDB::bind_method(D_METHOD("set_item_proto", "proto"), &M2Client::set_item_proto); - ClassDB::bind_method(D_METHOD("item_action_sound_for_item", "vnum", "drop"), - &M2Client::item_action_sound_for_item); - ClassDB::bind_method(D_METHOD("select_character", "index"), &M2Client::select_character); - ClassDB::bind_method(D_METHOD("enter_game", "index"), &M2Client::enter_game); - ClassDB::bind_method( - D_METHOD("create_character", "slot", "name", "job", "shape", "con", "int", "str", "dex"), - &M2Client::create_character); - ClassDB::bind_method(D_METHOD("delete_character", "slot", "private_code"), - &M2Client::delete_character); - ClassDB::bind_method(D_METHOD("change_name", "slot", "name"), &M2Client::change_name); - ClassDB::bind_method(D_METHOD("get_empire"), &M2Client::get_empire); - ClassDB::bind_method(D_METHOD("select_empire", "empire"), &M2Client::select_empire); - ClassDB::bind_method(D_METHOD("get_slot_count"), &M2Client::get_slot_count); - ClassDB::bind_method(D_METHOD("move", "func", "arg", "rot_deg", "x", "y"), &M2Client::move); - ClassDB::bind_method(D_METHOD("character_position", "position"), &M2Client::character_position); - ClassDB::bind_method(D_METHOD("sync_positions", "positions"), &M2Client::sync_positions); - ClassDB::bind_method(D_METHOD("request_warp"), &M2Client::request_warp); - ClassDB::bind_method(D_METHOD("fishing", "rot_deg"), &M2Client::fishing); - ClassDB::bind_method(D_METHOD("request_dungeon"), &M2Client::request_dungeon); - ClassDB::bind_method(D_METHOD("attack", "motion", "victim_vid"), &M2Client::attack); - ClassDB::bind_method(D_METHOD("set_target", "victim_vid"), &M2Client::set_target); - ClassDB::bind_method(D_METHOD("say", "type", "text"), &M2Client::say); - ClassDB::bind_method(D_METHOD("send_emoticon", "index"), &M2Client::send_emoticon); - ClassDB::bind_method(D_METHOD("whisper", "to", "text"), &M2Client::whisper); - ClassDB::bind_method(D_METHOD("cast_skill", "motion_idx", "rot_deg", "x", "y", "arg"), - &M2Client::cast_skill, DEFVAL(0)); - ClassDB::bind_method(D_METHOD("use_skill", "skill_id", "target_vid"), &M2Client::use_skill); - ClassDB::bind_method(D_METHOD("shoot", "skill_id"), &M2Client::shoot); - ClassDB::bind_method(D_METHOD("fly_targeting", "target_vid", "x", "y"), &M2Client::fly_targeting); - ClassDB::bind_method(D_METHOD("add_fly_targeting", "target_vid", "x", "y"), &M2Client::add_fly_targeting); - ClassDB::bind_method(D_METHOD("skill_up", "skill_id"), &M2Client::skill_up); - ClassDB::bind_method(D_METHOD("get_skill_group"), &M2Client::get_skill_group); - ClassDB::bind_method(D_METHOD("get_skills"), &M2Client::get_skills); - ClassDB::bind_method(D_METHOD("get_quickslots"), &M2Client::get_quickslots); - ClassDB::bind_method(D_METHOD("quickslot_add", "pos", "type", "ref"), &M2Client::quickslot_add); - ClassDB::bind_method(D_METHOD("quickslot_del", "pos"), &M2Client::quickslot_del); - ClassDB::bind_method(D_METHOD("quickslot_swap", "pos", "change_pos"), &M2Client::quickslot_swap); - ClassDB::bind_method(D_METHOD("click_npc", "vid"), &M2Client::click_npc); - ClassDB::bind_method(D_METHOD("script_answer", "answer"), &M2Client::script_answer); - ClassDB::bind_method(D_METHOD("script_button", "idx"), &M2Client::script_button); - ClassDB::bind_method(D_METHOD("script_select_item", "selection"), &M2Client::script_select_item); - ClassDB::bind_method(D_METHOD("quest_input", "text"), &M2Client::quest_input); - ClassDB::bind_method(D_METHOD("quest_confirm", "yes", "request_pid"), &M2Client::quest_confirm); - ClassDB::bind_method(D_METHOD("quest_cancel"), &M2Client::quest_cancel); - ClassDB::bind_method(D_METHOD("get_quests"), &M2Client::get_quests); - ClassDB::bind_method(D_METHOD("get_entity", "vid"), &M2Client::get_entity); - ClassDB::bind_method(D_METHOD("set_entity_motion_speed", "vid", "walk_cm_s", "run_cm_s"), - &M2Client::set_entity_motion_speed); - ClassDB::bind_method(D_METHOD("set_entity_network_state_gate", "vid", "skill_active", - "skill_can_cancel", "acting_emotion", "tcp_state_enabled"), - &M2Client::set_entity_network_state_gate); - ClassDB::bind_method(D_METHOD("set_entity_knockdown", "vid", "on"), - &M2Client::set_entity_knockdown); - ClassDB::bind_method(D_METHOD("get_entities"), &M2Client::get_entities); - ClassDB::bind_method(D_METHOD("get_main_vid"), &M2Client::get_main_vid); - ClassDB::bind_method(D_METHOD("get_main_pid"), &M2Client::get_main_pid); - ClassDB::bind_method(D_METHOD("get_points"), &M2Client::get_points); - ClassDB::bind_method(D_METHOD("get_target"), &M2Client::get_target); - ClassDB::bind_method(D_METHOD("get_affects"), &M2Client::get_affects); - ClassDB::bind_method(D_METHOD("move_item", "from_window", "from_cell", "to_window", "to_cell", "count"), - &M2Client::move_item); - ClassDB::bind_method(D_METHOD("use_item", "window", "cell"), &M2Client::use_item); - ClassDB::bind_method(D_METHOD("drop_item", "window", "cell", "gold"), &M2Client::drop_item); - ClassDB::bind_method(D_METHOD("drop_item_count", "window", "cell", "gold", "count"), &M2Client::drop_item_count); - ClassDB::bind_method(D_METHOD("use_item_to_item", "source_window", "source_cell", "target_window", "target_cell"), - &M2Client::use_item_to_item); - ClassDB::bind_method(D_METHOD("give_item", "target_vid", "window", "cell", "count"), &M2Client::give_item); - ClassDB::bind_method(D_METHOD("pickup_item", "ground_vid"), &M2Client::pickup_item); - ClassDB::bind_method(D_METHOD("get_inventory"), &M2Client::get_inventory); - ClassDB::bind_method(D_METHOD("get_equipment"), &M2Client::get_equipment); - ClassDB::bind_method(D_METHOD("get_belt_inventory"), &M2Client::get_belt_inventory); - ClassDB::bind_method(D_METHOD("get_view_equipment", "vid"), &M2Client::get_view_equipment); - ClassDB::bind_method(D_METHOD("get_item", "window", "cell"), &M2Client::get_item); - ClassDB::bind_method(D_METHOD("get_ground_items"), &M2Client::get_ground_items); - ClassDB::bind_method(D_METHOD("get_pvp_relations"), &M2Client::get_pvp_relations); - ClassDB::bind_method(D_METHOD("get_duel"), &M2Client::get_duel); - ClassDB::bind_method(D_METHOD("suspend"), &M2Client::suspend); - ClassDB::bind_method(D_METHOD("resume"), &M2Client::resume); - ClassDB::bind_method(D_METHOD("reconnect"), &M2Client::reconnect); - ClassDB::bind_method(D_METHOD("is_suspended"), &M2Client::is_suspended); - ClassDB::bind_method(D_METHOD("get_stage"), &M2Client::get_stage); - ClassDB::bind_method(D_METHOD("is_in_game"), &M2Client::is_in_game); - ClassDB::bind_method(D_METHOD("net_poll"), &M2Client::net_poll); - - ADD_SIGNAL(MethodInfo("stage_changed", PropertyInfo(Variant::STRING, "stage"))); - ADD_SIGNAL(MethodInfo("phase_changed", PropertyInfo(Variant::STRING, "phase"))); - ADD_SIGNAL(MethodInfo("auth_ok", PropertyInfo(Variant::INT, "login_key"))); - ADD_SIGNAL(MethodInfo("login_failed", PropertyInfo(Variant::STRING, "reason"))); - ADD_SIGNAL(MethodInfo("connect_failed", PropertyInfo(Variant::STRING, "reason"))); - ADD_SIGNAL(MethodInfo("server_quit_requested")); - ADD_SIGNAL(MethodInfo("optional_security_required", PropertyInfo(Variant::INT, "header"), - PropertyInfo(Variant::STRING, "reason"))); - ADD_SIGNAL(MethodInfo("char_list", PropertyInfo(Variant::ARRAY, "characters"))); - ADD_SIGNAL(MethodInfo("char_created", PropertyInfo(Variant::INT, "slot"))); - ADD_SIGNAL(MethodInfo("char_create_failed", PropertyInfo(Variant::INT, "reason_type"))); - ADD_SIGNAL(MethodInfo("char_deleted", PropertyInfo(Variant::INT, "slot"))); - ADD_SIGNAL(MethodInfo("char_delete_failed")); - ADD_SIGNAL(MethodInfo("empire_changed", PropertyInfo(Variant::INT, "empire"))); - ADD_SIGNAL(MethodInfo("char_name_changed", PropertyInfo(Variant::INT, "pid"), - PropertyInfo(Variant::STRING, "name"))); - ADD_SIGNAL(MethodInfo("entered_game")); - ADD_SIGNAL(MethodInfo("disconnected", PropertyInfo(Variant::STRING, "reason"))); - ADD_SIGNAL(MethodInfo("suspended")); - ADD_SIGNAL(MethodInfo("resumed")); - // networked world (fed from EntityStore each pump) - ADD_SIGNAL(MethodInfo("entity_spawned", PropertyInfo(Variant::DICTIONARY, "entity"))); - ADD_SIGNAL(MethodInfo("entity_despawned", PropertyInfo(Variant::INT, "vid"))); - ADD_SIGNAL(MethodInfo("entity_moved", PropertyInfo(Variant::INT, "vid"))); - ADD_SIGNAL(MethodInfo("entity_main_set", PropertyInfo(Variant::INT, "vid"))); - // name/level/guild/mount arrived (GC_CHAR_ADD_INFO) for an already-spawned entity. - ADD_SIGNAL(MethodInfo("entity_info", PropertyInfo(Variant::INT, "vid"), - PropertyInfo(Variant::DICTIONARY, "entity"))); - ADD_SIGNAL(MethodInfo("chat", PropertyInfo(Variant::INT, "type"), - PropertyInfo(Variant::INT, "vid"), PropertyInfo(Variant::STRING, "text"))); - ADD_SIGNAL(MethodInfo("emoticon_requested", PropertyInfo(Variant::INT, "index"))); - ADD_SIGNAL(MethodInfo("whisper_received", PropertyInfo(Variant::INT, "sub"), - PropertyInfo(Variant::STRING, "from"), PropertyInfo(Variant::STRING, "text"))); - // combat / status - ADD_SIGNAL(MethodInfo("vitals_changed", PropertyInfo(Variant::INT, "vid"))); - ADD_SIGNAL(MethodInfo("entity_dead", PropertyInfo(Variant::INT, "vid"))); - ADD_SIGNAL(MethodInfo("damage", PropertyInfo(Variant::INT, "vid"), - PropertyInfo(Variant::INT, "amount"), PropertyInfo(Variant::INT, "flag"))); - ADD_SIGNAL(MethodInfo("motion", PropertyInfo(Variant::INT, "vid"), - PropertyInfo(Variant::INT, "victim_vid"), PropertyInfo(Variant::INT, "motion"))); - ADD_SIGNAL(MethodInfo("dig_motion", PropertyInfo(Variant::INT, "vid"), - PropertyInfo(Variant::INT, "target_vid"), PropertyInfo(Variant::INT, "count"))); - ADD_SIGNAL(MethodInfo("point_changed", PropertyInfo(Variant::INT, "vid"), - PropertyInfo(Variant::INT, "type"), PropertyInfo(Variant::INT, "value"), - PropertyInfo(Variant::INT, "amount"))); - ADD_SIGNAL(MethodInfo("points_changed", PropertyInfo(Variant::DICTIONARY, "points"))); - ADD_SIGNAL(MethodInfo("target_info", PropertyInfo(Variant::INT, "vid"), - PropertyInfo(Variant::INT, "hp_percent"))); - // items - ADD_SIGNAL(MethodInfo("inventory_changed", PropertyInfo(Variant::INT, "window"), - PropertyInfo(Variant::INT, "cell"))); - ADD_SIGNAL(MethodInfo("inventory_state_changed", PropertyInfo(Variant::INT, "window"), - PropertyInfo(Variant::INT, "cell"), PropertyInfo(Variant::DICTIONARY, "item"))); - ADD_SIGNAL(MethodInfo("item_highlighted", PropertyInfo(Variant::INT, "window"), - PropertyInfo(Variant::INT, "cell"))); - ADD_SIGNAL(MethodInfo("item_action_sound", PropertyInfo(Variant::STRING, "sound"))); - ADD_SIGNAL(MethodInfo("ground_item_added", PropertyInfo(Variant::DICTIONARY, "item"))); - ADD_SIGNAL(MethodInfo("ground_item_removed", PropertyInfo(Variant::INT, "vid"))); - ADD_SIGNAL(MethodInfo("view_equipment", PropertyInfo(Variant::INT, "vid"))); - ADD_SIGNAL(MethodInfo("pvp_changed", PropertyInfo(Variant::INT, "src_vid"), - PropertyInfo(Variant::INT, "dst_vid"), PropertyInfo(Variant::INT, "mode"))); - ADD_SIGNAL(MethodInfo("duel_changed", PropertyInfo(Variant::DICTIONARY, "duel"))); - ADD_SIGNAL(MethodInfo("duel_started")); - ADD_SIGNAL(MethodInfo("item_picked_up", PropertyInfo(Variant::INT, "vnum"), - PropertyInfo(Variant::INT, "count"), PropertyInfo(Variant::STRING, "from"))); - ADD_SIGNAL(MethodInfo("item_used", PropertyInfo(Variant::INT, "vnum"))); - ADD_SIGNAL(MethodInfo("affect_added", PropertyInfo(Variant::DICTIONARY, "affect"))); - ADD_SIGNAL(MethodInfo("affect_removed", PropertyInfo(Variant::INT, "type"))); - // P5: server-triggered effects. name is a .mse path for GC_SPECIFIC_EFFECT, - // "" for GC_SPECIAL_EFFECT (use `special` id + a client-side table). - ADD_SIGNAL(MethodInfo("effect_cue", PropertyInfo(Variant::INT, "vid"), - PropertyInfo(Variant::STRING, "name"), PropertyInfo(Variant::INT, "special"))); - // P6: skills - ADD_SIGNAL(MethodInfo("skills_changed")); - ADD_SIGNAL(MethodInfo("skill_group_changed", PropertyInfo(Variant::INT, "skill_group"))); - ADD_SIGNAL(MethodInfo("quickslots_changed")); - ADD_SIGNAL(MethodInfo("skill_cooldown_end", PropertyInfo(Variant::INT, "skill"))); - ADD_SIGNAL(MethodInfo("fly_cue", PropertyInfo(Variant::INT, "type"), - PropertyInfo(Variant::INT, "start_vid"), PropertyInfo(Variant::INT, "end_vid"))); - ADD_SIGNAL(MethodInfo("fly_targeting", PropertyInfo(Variant::INT, "shooter_vid"), - PropertyInfo(Variant::INT, "target_vid"), PropertyInfo(Variant::VECTOR2, "target_cm"), - PropertyInfo(Variant::BOOL, "append"))); - // P7: quest / NPC - ADD_SIGNAL(MethodInfo("script_dialog", PropertyInfo(Variant::INT, "skin"), - PropertyInfo(Variant::STRING, "text"))); - ADD_SIGNAL(MethodInfo("quest_confirm_ask", PropertyInfo(Variant::STRING, "msg"), - PropertyInfo(Variant::INT, "timeout"), PropertyInfo(Variant::INT, "request_pid"))); - ADD_SIGNAL(MethodInfo("quest_info", PropertyInfo(Variant::INT, "index"))); - // P8: social / shop / storage - ClassDB::bind_method(D_METHOD("party_invite", "vid"), &M2Client::party_invite); - ClassDB::bind_method(D_METHOD("party_answer", "leader_pid", "accept"), &M2Client::party_answer); - ClassDB::bind_method(D_METHOD("party_leave", "pid"), &M2Client::party_leave); - ClassDB::bind_method(D_METHOD("party_use_skill", "skill_index", "target_vid"), &M2Client::party_use_skill); - ClassDB::bind_method(D_METHOD("party_set_distribute", "mode"), &M2Client::party_set_distribute); - ClassDB::bind_method(D_METHOD("party_set_state", "pid", "role", "on"), &M2Client::party_set_state); - ClassDB::bind_method(D_METHOD("get_party"), &M2Client::get_party); - ClassDB::bind_method(D_METHOD("get_party_distribute_mode"), &M2Client::get_party_distribute_mode); - ClassDB::bind_method(D_METHOD("add_friend", "name"), &M2Client::add_friend); - ClassDB::bind_method(D_METHOD("remove_friend", "name"), &M2Client::remove_friend); - ClassDB::bind_method(D_METHOD("friend_answer", "name", "accept"), &M2Client::friend_answer); - ClassDB::bind_method(D_METHOD("get_friends"), &M2Client::get_friends); - ClassDB::bind_method(D_METHOD("get_lover"), &M2Client::get_lover); - ClassDB::bind_method(D_METHOD("shop_buy", "pos", "count"), &M2Client::shop_buy); - ClassDB::bind_method(D_METHOD("shop_sell", "inv_cell", "count"), &M2Client::shop_sell); - ClassDB::bind_method(D_METHOD("shop_close"), &M2Client::shop_close); - ClassDB::bind_method(D_METHOD("is_shop_open"), &M2Client::is_shop_open); - ClassDB::bind_method(D_METHOD("get_shop_items"), &M2Client::get_shop_items); - ClassDB::bind_method(D_METHOD("get_shop"), &M2Client::get_shop); - ClassDB::bind_method(D_METHOD("exchange_start", "vid"), &M2Client::exchange_start); - ClassDB::bind_method(D_METHOD("exchange_add_item", "inv_window", "inv_cell", "display_pos"), - &M2Client::exchange_add_item); - ClassDB::bind_method(D_METHOD("exchange_add_gold", "gold"), &M2Client::exchange_add_gold); - ClassDB::bind_method(D_METHOD("exchange_accept"), &M2Client::exchange_accept); - ClassDB::bind_method(D_METHOD("exchange_cancel"), &M2Client::exchange_cancel); - ClassDB::bind_method(D_METHOD("get_exchange"), &M2Client::get_exchange); - ClassDB::bind_method(D_METHOD("safebox_checkin", "safe_pos", "inv_window", "inv_cell"), - &M2Client::safebox_checkin); - ClassDB::bind_method(D_METHOD("safebox_checkout", "safe_pos", "inv_window", "inv_cell"), - &M2Client::safebox_checkout); - ClassDB::bind_method(D_METHOD("safebox_move", "from_cell", "to_cell", "count"), - &M2Client::safebox_move); - ClassDB::bind_method(D_METHOD("is_safebox_open"), &M2Client::is_safebox_open); - ClassDB::bind_method(D_METHOD("get_safebox_size"), &M2Client::get_safebox_size); - ClassDB::bind_method(D_METHOD("get_safebox_gold"), &M2Client::get_safebox_gold); - ClassDB::bind_method(D_METHOD("get_safebox_items"), &M2Client::get_safebox_items); - ClassDB::bind_method(D_METHOD("safebox_password", "password"), &M2Client::safebox_password); - ClassDB::bind_method(D_METHOD("safebox_change_password", "old_password", "new_password"), - &M2Client::safebox_change_password); - ClassDB::bind_method(D_METHOD("safebox_close"), &M2Client::safebox_close); - // item-mall (창고몰) - ClassDB::bind_method(D_METHOD("is_mall_open"), &M2Client::is_mall_open); - ClassDB::bind_method(D_METHOD("get_mall_size"), &M2Client::get_mall_size); - ClassDB::bind_method(D_METHOD("get_mall_items"), &M2Client::get_mall_items); - ClassDB::bind_method(D_METHOD("mall_checkout", "mall_pos", "inv_window", "inv_cell"), - &M2Client::mall_checkout); - ClassDB::bind_method(D_METHOD("mall_password", "password"), &M2Client::mall_password); - ClassDB::bind_method(D_METHOD("mall_close"), &M2Client::mall_close); - // private (PC) shop - ClassDB::bind_method(D_METHOD("open_private_shop", "sign", "items"), - &M2Client::open_private_shop); - ClassDB::bind_method(D_METHOD("close_private_shop"), &M2Client::close_private_shop); - // cube (제작) - ClassDB::bind_method(D_METHOD("get_cube"), &M2Client::get_cube); - ClassDB::bind_method(D_METHOD("cube_make", "result_index"), &M2Client::cube_make); - ClassDB::bind_method(D_METHOD("cube_request_result_list", "npc_vnum"), - &M2Client::cube_request_result_list); - ClassDB::bind_method(D_METHOD("cube_request_materials", "start_index", "count"), - &M2Client::cube_request_materials); - ClassDB::bind_method(D_METHOD("cube_open"), &M2Client::cube_open); - ClassDB::bind_method(D_METHOD("cube_close"), &M2Client::cube_close); - ClassDB::bind_method(D_METHOD("cube_list"), &M2Client::cube_list); - ClassDB::bind_method(D_METHOD("cube_add_item", "cube_index", "inventory_index"), - &M2Client::cube_add_item); - ClassDB::bind_method(D_METHOD("cube_delete_item", "cube_index"), &M2Client::cube_delete_item); - - ADD_SIGNAL(MethodInfo("party_changed")); - ADD_SIGNAL(MethodInfo("party_invite_ask", PropertyInfo(Variant::INT, "leader_pid"))); - ADD_SIGNAL(MethodInfo("party_request_denied")); - ADD_SIGNAL(MethodInfo("lover_changed", PropertyInfo(Variant::DICTIONARY, "lover"))); - ADD_SIGNAL(MethodInfo("friends_changed")); - ADD_SIGNAL(MethodInfo("friend_invite_ask", PropertyInfo(Variant::STRING, "name"))); - ADD_SIGNAL(MethodInfo("shop_opened", PropertyInfo(Variant::INT, "vid"))); - ADD_SIGNAL(MethodInfo("shop_closed")); - ADD_SIGNAL(MethodInfo("shop_updated", PropertyInfo(Variant::INT, "pos"))); - ADD_SIGNAL(MethodInfo("shop_error", PropertyInfo(Variant::STRING, "kind"))); - ADD_SIGNAL(MethodInfo("exchange_changed")); - ADD_SIGNAL(MethodInfo("safebox_opened", PropertyInfo(Variant::INT, "size"))); - ADD_SIGNAL(MethodInfo("safebox_closed")); - ADD_SIGNAL(MethodInfo("safebox_changed")); - ADD_SIGNAL(MethodInfo("safebox_password_required")); - ADD_SIGNAL(MethodInfo("safebox_wrong_password")); - ADD_SIGNAL(MethodInfo("mall_opened", PropertyInfo(Variant::INT, "size"))); - ADD_SIGNAL(MethodInfo("mall_closed")); - ADD_SIGNAL(MethodInfo("mall_changed")); - ADD_SIGNAL(MethodInfo("mall_password_required")); - ADD_SIGNAL(MethodInfo("private_shop_open_requested")); - ADD_SIGNAL(MethodInfo("my_shop_price_list", PropertyInfo(Variant::INT, "vnum"), - PropertyInfo(Variant::INT, "price"))); - ADD_SIGNAL(MethodInfo("block_mode_changed", PropertyInfo(Variant::INT, "mask"))); - ADD_SIGNAL(MethodInfo("observer_mode_changed", PropertyInfo(Variant::BOOL, "enabled"))); - ADD_SIGNAL(MethodInfo("observer_count_changed", PropertyInfo(Variant::INT, "count"))); - ADD_SIGNAL(MethodInfo("stone_detected", PropertyInfo(Variant::INT, "vid"), - PropertyInfo(Variant::INT, "distance"), PropertyInfo(Variant::FLOAT, "angle"))); - ADD_SIGNAL(MethodInfo("stamina_changed", PropertyInfo(Variant::INT, "current"), - PropertyInfo(Variant::INT, "per_sec"), PropertyInfo(Variant::BOOL, "consuming"))); - ADD_SIGNAL(MethodInfo("mobile_flag_changed", PropertyInfo(Variant::BOOL, "enabled"))); - ADD_SIGNAL(MethodInfo("mobile_auth_required")); - ADD_SIGNAL(MethodInfo("combo_changed", PropertyInfo(Variant::BOOL, "enabled"))); - ADD_SIGNAL(MethodInfo("gift_available")); - ADD_SIGNAL(MethodInfo("weather_changed", PropertyInfo(Variant::BOOL, "enabled"))); - ADD_SIGNAL(MethodInfo("cube_opened", PropertyInfo(Variant::INT, "npc_vnum"))); - ADD_SIGNAL(MethodInfo("cube_closed")); - ADD_SIGNAL(MethodInfo("cube_changed")); - ADD_SIGNAL(MethodInfo("cube_result", PropertyInfo(Variant::INT, "vnum"), - PropertyInfo(Variant::INT, "count"), PropertyInfo(Variant::BOOL, "ok"))); - - // guild / refine - ClassDB::bind_method(D_METHOD("get_guild"), &M2Client::get_guild); - ClassDB::bind_method(D_METHOD("get_guild_members"), &M2Client::get_guild_members); - ClassDB::bind_method(D_METHOD("get_guild_grades"), &M2Client::get_guild_grades); - ClassDB::bind_method(D_METHOD("guild_add_member", "vid"), &M2Client::guild_add_member); - ClassDB::bind_method(D_METHOD("guild_remove_member", "pid"), &M2Client::guild_remove_member); - ClassDB::bind_method(D_METHOD("guild_offer", "amount"), &M2Client::guild_offer); - ClassDB::bind_method(D_METHOD("guild_charge_gsp", "amount"), &M2Client::guild_charge_gsp); - ClassDB::bind_method(D_METHOD("guild_deposit_money", "amount"), &M2Client::guild_deposit_money); - ClassDB::bind_method(D_METHOD("guild_withdraw_money", "amount"), &M2Client::guild_withdraw_money); - ClassDB::bind_method(D_METHOD("guild_change_grade_name", "grade", "name"), - &M2Client::guild_change_grade_name); - ClassDB::bind_method(D_METHOD("guild_change_grade_authority", "grade", "authority"), - &M2Client::guild_change_grade_authority); - ClassDB::bind_method(D_METHOD("guild_change_member_grade", "pid", "grade"), - &M2Client::guild_change_member_grade); - ClassDB::bind_method(D_METHOD("guild_change_member_general", "pid", "enabled"), - &M2Client::guild_change_member_general); - ClassDB::bind_method(D_METHOD("guild_post_comment", "text"), &M2Client::guild_post_comment); - ClassDB::bind_method(D_METHOD("guild_delete_comment", "comment_id"), &M2Client::guild_delete_comment); - ClassDB::bind_method(D_METHOD("guild_refresh_comments"), &M2Client::guild_refresh_comments); - ClassDB::bind_method(D_METHOD("guild_answer_invite", "guild_id", "accept"), - &M2Client::guild_answer_invite); - ClassDB::bind_method(D_METHOD("guild_answer_make", "name"), &M2Client::guild_answer_make); - ClassDB::bind_method(D_METHOD("get_guild_comments"), &M2Client::get_guild_comments); - ClassDB::bind_method(D_METHOD("get_guild_skill"), &M2Client::get_guild_skill); - ClassDB::bind_method(D_METHOD("get_guild_wars"), &M2Client::get_guild_wars); - ClassDB::bind_method(D_METHOD("get_guild_war"), &M2Client::get_guild_war); - ClassDB::bind_method(D_METHOD("get_guild_name", "guild_id"), &M2Client::get_guild_name); - ClassDB::bind_method(D_METHOD("use_guild_skill", "skill_vnum", "target_vid"), - &M2Client::use_guild_skill); - ClassDB::bind_method(D_METHOD("declare_guild_war", "guild_name"), &M2Client::declare_guild_war); - ADD_SIGNAL(MethodInfo("guild_skill_changed")); - ADD_SIGNAL(MethodInfo("guild_war_changed")); - ADD_SIGNAL(MethodInfo("guild_comments_changed")); - ADD_SIGNAL(MethodInfo("guild_invite_ask", PropertyInfo(Variant::INT, "guild_id"), - PropertyInfo(Variant::STRING, "guild_name"))); - ADD_SIGNAL(MethodInfo("guild_war_event", PropertyInfo(Variant::INT, "state"), - PropertyInfo(Variant::INT, "opp_guild_id"), PropertyInfo(Variant::STRING, "opp_name"))); - ADD_SIGNAL(MethodInfo("guild_war_point", PropertyInfo(Variant::INT, "gain_guild_id"), - PropertyInfo(Variant::INT, "opp_guild_id"), PropertyInfo(Variant::INT, "point"))); - ClassDB::bind_method(D_METHOD("download_guild_marks", "host", "port"), - &M2Client::download_guild_marks); - ClassDB::bind_method(D_METHOD("download_guild_symbol", "host", "port", "guild_id"), - &M2Client::download_guild_symbol); - ClassDB::bind_method(D_METHOD("get_guild_symbol"), &M2Client::get_guild_symbol); - ClassDB::bind_method(D_METHOD("are_guild_marks_ready"), &M2Client::are_guild_marks_ready); - ClassDB::bind_method(D_METHOD("get_mark_server"), &M2Client::get_mark_server); - ClassDB::bind_method(D_METHOD("get_guild_mark", "guild_id"), &M2Client::get_guild_mark); - ClassDB::bind_method(D_METHOD("get_guild_mark_image", "guild_id"), &M2Client::get_guild_mark_image); - ClassDB::bind_method(D_METHOD("upload_guild_mark", "host", "port", "guild_id", "img"), - &M2Client::upload_guild_mark); - ClassDB::bind_method(D_METHOD("upload_guild_symbol", "host", "port", "guild_id", "file_bytes"), - &M2Client::upload_guild_symbol); - ADD_SIGNAL(MethodInfo("guild_marks_ready", PropertyInfo(Variant::INT, "mark_count"))); - ADD_SIGNAL(MethodInfo("guild_symbol_ready", PropertyInfo(Variant::INT, "guild_id"), - PropertyInfo(Variant::PACKED_BYTE_ARRAY, "data"))); - ADD_SIGNAL(MethodInfo("guild_mark_uploaded", PropertyInfo(Variant::BOOL, "ok"))); - ADD_SIGNAL(MethodInfo("guild_mark_updated", PropertyInfo(Variant::INT, "guild_id"), - PropertyInfo(Variant::INT, "img_idx"))); - ClassDB::bind_method(D_METHOD("refine", "pos", "type"), &M2Client::refine); - ClassDB::bind_method(D_METHOD("ds_refine", "mode", "cells"), &M2Client::ds_refine); - ClassDB::bind_method(D_METHOD("get_dragon_souls"), &M2Client::get_dragon_souls); - ADD_SIGNAL(MethodInfo("guild_changed")); - ADD_SIGNAL(MethodInfo("guild_make_requested")); - ADD_SIGNAL(MethodInfo("refine_ask", PropertyInfo(Variant::DICTIONARY, "info"))); - ADD_SIGNAL(MethodInfo("refine_result", PropertyInfo(Variant::BOOL, "ok"))); - ADD_SIGNAL(MethodInfo("ds_window_open")); - ADD_SIGNAL(MethodInfo("ds_refine_result", PropertyInfo(Variant::BOOL, "ok"), - PropertyInfo(Variant::INT, "sub_type"), PropertyInfo(Variant::INT, "cell"))); - - // P9: world systems - ClassDB::bind_method(D_METHOD("get_channel"), &M2Client::get_channel); - ClassDB::bind_method(D_METHOD("get_server_time"), &M2Client::get_server_time); - ClassDB::bind_method(D_METHOD("get_npc_marks"), &M2Client::get_npc_marks); - ClassDB::bind_method(D_METHOD("get_land_areas"), &M2Client::get_land_areas); - ClassDB::bind_method(D_METHOD("get_observers"), &M2Client::get_observers); - ClassDB::bind_method(D_METHOD("get_world_markers"), &M2Client::get_world_markers); - ClassDB::bind_method(D_METHOD("is_observer_mode"), &M2Client::is_observer_mode); - ClassDB::bind_method(D_METHOD("get_observer_count"), &M2Client::get_observer_count); - ClassDB::bind_method(D_METHOD("has_mobile_flag"), &M2Client::has_mobile_flag); - ClassDB::bind_method(D_METHOD("combo_skill_enabled"), &M2Client::combo_skill_enabled); - ClassDB::bind_method(D_METHOD("get_stamina_state"), &M2Client::get_stamina_state); - ADD_SIGNAL(MethodInfo("warp", PropertyInfo(Variant::VECTOR3, "pos"), - PropertyInfo(Variant::BOOL, "same_server"))); - ADD_SIGNAL(MethodInfo("world_reset")); - ADD_SIGNAL(MethodInfo("time_changed", PropertyInfo(Variant::INT, "server_epoch"))); - ADD_SIGNAL(MethodInfo("channel_changed", PropertyInfo(Variant::INT, "channel"))); - ADD_SIGNAL(MethodInfo("npc_marks_changed")); - ADD_SIGNAL(MethodInfo("land_areas_changed")); - ADD_SIGNAL(MethodInfo("observer_event", PropertyInfo(Variant::INT, "kind"), - PropertyInfo(Variant::INT, "vid"), PropertyInfo(Variant::VECTOR2, "pos"))); - ADD_SIGNAL(MethodInfo("fishing_event", PropertyInfo(Variant::INT, "subheader"), - PropertyInfo(Variant::INT, "info"), PropertyInfo(Variant::INT, "dir"))); - ADD_SIGNAL(MethodInfo("dungeon_event", PropertyInfo(Variant::INT, "subheader"), - PropertyInfo(Variant::INT, "x"), PropertyInfo(Variant::INT, "y"), - PropertyInfo(Variant::BOOL, "has_destination"))); - ADD_SIGNAL(MethodInfo("world_markers_changed")); - ADD_SIGNAL(MethodInfo("mount_changed", PropertyInfo(Variant::INT, "vid"))); - // §9.1 map background music: name is the resolved track id/path, volume is - // 0..1 or -1 when the server left it unspecified. - ADD_SIGNAL(MethodInfo("bgm_changed", PropertyInfo(Variant::STRING, "name"), - PropertyInfo(Variant::FLOAT, "volume"))); -} - -static const char *phase_name(int p) { - switch (p) { - case mtnet::PHASE_CLOSE: return "close"; - case mtnet::PHASE_HANDSHAKE: return "handshake"; - case mtnet::PHASE_LOGIN: return "login"; - case mtnet::PHASE_SELECT: return "select"; - case mtnet::PHASE_LOADING: return "loading"; - case mtnet::PHASE_GAME: return "game"; - case mtnet::PHASE_DEAD: return "dead"; - case mtnet::PHASE_AUTH: return "auth"; - default: return "?"; - } -} - -String M2Client::stage_name() const { - switch (stage) { - case Stage::Idle: return "idle"; - case Stage::AuthConnect: return "auth_connect"; - case Stage::AuthWait: return "auth_wait"; - case Stage::GameConnect: return "game_connect"; - case Stage::GameLogin: return "game_login"; - case Stage::InGame: return "in_game"; - case Stage::Failed: return "failed"; - } - return "?"; -} - -void M2Client::set_stage(Stage s) { - if (stage == s) { - return; - } - stage = s; - emit_signal("stage_changed", stage_name()); -} - -bool M2Client::is_in_game() const { - return stage == Stage::InGame; -} - -void M2Client::net_poll() { - if (suspended) { - return; - } - if (auth) { - auth->process(); - } - if (game) { - game->process(); - } - if (classic_sess) { - classic_sess->set_now((uint32_t)Time::get_singleton()->get_ticks_msec()); - classic_sess->pump(); - } - if (mark) { - mark->process(); - } - if (classic_mark) { - classic_mark->process(); - } -} - -void M2Client::connect_to_server(const String &auth_host, int auth_port, const String &g_host, - int g_port, const String &id, const String &pw) { - disconnect_from_server(); - if (auth_host.is_empty() || g_host.is_empty() || auth_port <= 0 || g_port <= 0 || - !mtnet::bounds::u16(auth_port) || !mtnet::bounds::u16(g_port)) { - emit_signal("login_failed", String("invalid server address")); - set_stage(Stage::Failed); - return; - } - game_host = g_host; - game_port = g_port; - account_id = id; - - cfg_auth_host = auth_host; - cfg_auth_port = auth_port; - cfg_game_host = g_host; - cfg_game_port = g_port; - cfg_id = id; - cfg_pw = pw; - have_cfg = true; - suspended = false; - classic_remote_disconnect_pending = false; - classic_auth_connect_failure_pending = false; - classic_auth_remote_disconnect_pending = false; - - // MT_PROTOCOL=classic -> 40250 backend: authenticate on auth_host first, - // then connect to game_host with the returned login ticket. - if (OS::get_singleton()->get_environment("MT_PROTOCOL") == "classic") { - classic_sess = std::make_unique(); - classic_sess->on_login_failure = [this](const std::string &reason) { - emit_signal("login_failed", String(reason.c_str())); - }; - classic_sess->on_connect_failure = [this](const std::string &reason) { - // 40250 CPythonNetworkStream::OnConnectFailure calls the LoginWindow - // owner, not its credential-rejection callback. - emit_signal("connect_failed", String(reason.c_str())); - }; - classic_sess->on_auth_connect_failure = [this](const std::string &reason) { - // CAccountConnector::OnConnectFailure -> __OfflineState_Set keeps - // LoginWindow as the retry owner. Do not let the later Offline pump - // synthesize login_failed/Failed for this same transport event. - classic_auth_connect_failure_pending = true; - set_stage(Stage::AuthConnect); - emit_signal("connect_failed", String(reason.c_str())); - }; - classic_sess->on_auth_remote_disconnect = [this](const std::string &) { - // CAccountConnector::OnRemoteDisconnect -> __OfflineState_Set has no - // LoginWindow failure callback. Keep the LoginPhase owner alive without - // converting a peer close into login_failed or connect_failed. - classic_auth_remote_disconnect_pending = true; - set_stage(Stage::AuthConnect); - }; - classic_sess->on_unsupported_security = [this](uint8_t header) { - emit_signal("optional_security_required", static_cast(header), - String("unsupported optional security packet GC header ") + - String::num_int64(header)); - }; - classic_sess->on_remote_disconnect = [this](const std::string &reason) { - classic_remote_disconnect_pending = true; - // ClassicSession::enter_login_phase has already run the reference - // phase-leave owner (and emitted world_reset for GamePhase) before this - // observer is called. Do not clear or notify a second time here. - emit_signal("disconnected", String(reason.c_str())); - }; - bind_classic_loading_reset(); - classic_sess->set_wire_trace(OS::get_singleton()->get_environment("MT_NET_TRACE") == "1"); - // §1.5 (W1 G1 wiring): CG_CLIENT_VERSION2 reports the running binary's - // filename; feed it the real executable name rather than the built-in - // default so the server-side version gate sees what ClientVS22 sends. - classic_sess->set_executable_name( - std::string(OS::get_singleton()->get_executable_path().get_file().utf8().get_data())); - // §2.2 (W1 G1 wiring): non-PC spawn names come from root/npclist.txt. - // M2Client has no asset-root handle, so the parser falls back to - // $MT_ASSETS/$M2_ASSETS (see npc_names.h default_npclist_path()); an - // explicit set_npclist_path() awaits asset-root plumbing into M2Client. - if (const String assets = OS::get_singleton()->get_environment("MT_ASSETS"); !assets.is_empty()) { - classic_sess->parser().set_npclist_path( - std::string(assets.utf8().get_data()) + "/root/npclist.txt"); - } - last_login_key = 0; - classic_list_emitted = false; - classic_last_stage = -1; - classic_last_empire = -1; - shop_open_seen = false; - mall_open_seen = false; - safebox_open_seen = false; - last_game_phase = -1; - if (!classic_sess->connect(std::string(auth_host.utf8().get_data()), (uint16_t)auth_port, - std::string(g_host.utf8().get_data()), (uint16_t)g_port, - std::string(id.utf8().get_data()), std::string(pw.utf8().get_data()))) { - if (classic_auth_connect_failure_pending) { - // Retain the ClassicSession and its LoginPhase retry owner. The - // next LoginWindow.Connect replaces only the auth transport. - return; - } - emit_signal("login_failed", String(classic_sess->last_error().c_str())); - set_stage(Stage::Failed); - classic_sess.reset(); - return; - } - set_stage(Stage::GameConnect); - return; - } - - auth = std::make_unique(std::string(id.utf8().get_data()), - std::string(pw.utf8().get_data())); - char_list_emitted = false; - last_auth_state = last_game_state = last_game_phase = last_game_empire = -1; - - if (!auth->connect(std::string(auth_host.utf8().get_data()), (uint16_t)auth_port)) { - emit_signal("login_failed", String(auth->last_error().c_str())); - set_stage(Stage::Failed); - auth.reset(); - return; - } - set_stage(Stage::AuthConnect); -} - -void M2Client::retry_login(const String &auth_host, int auth_port, const String &g_host, - int g_port, const String &id, const String &pw) { - if (classic_sess && classic_sess->login_retry_pending() && - OS::get_singleton()->get_environment("MT_PROTOCOL") == "classic") { - // 40250 LoginWindow.Connect -> MainStream.Connect -> - // CAccountConnector::Connect replaces only the auth transport. Keep the - // existing ClassicSession callbacks and LoginPhase owner; do not invoke - // the global transport/world teardown or emit a duplicate reset. - cfg_auth_host = auth_host; - cfg_auth_port = auth_port; - cfg_game_host = g_host; - cfg_game_port = g_port; - cfg_id = id; - cfg_pw = pw; - have_cfg = true; - suspended = false; - classic_remote_disconnect_pending = false; - classic_auth_connect_failure_pending = false; - classic_auth_remote_disconnect_pending = false; - last_login_key = 0; - if (!classic_sess->retry_login(std::string(auth_host.utf8().get_data()), - (uint16_t)auth_port, std::string(g_host.utf8().get_data()), - (uint16_t)g_port, std::string(id.utf8().get_data()), - std::string(pw.utf8().get_data()))) { - if (classic_auth_connect_failure_pending) { - return; - } - emit_signal("login_failed", String(classic_sess->last_error().c_str())); - set_stage(Stage::Failed); - return; - } - set_stage(Stage::GameConnect); - return; - } - // Initial login, or a manual retry after a non-login phase, retains the - // existing full-connect cleanup behavior. - connect_to_server(auth_host, auth_port, g_host, g_port, id, pw); -} - -void M2Client::disconnect_from_server() { - // ClientVS22's SetOffLinePhase/SetGamePhase leave path clears the player, - // actor, flying and effect owners before the transport is torn down. Keep - // that ordering when a GameScene is retained by AppFlow during a manual - // disconnect or a reconnect transition; destroying the session alone would - // leave the scene's presentation state pointing at the old world. - if (classic_sess) { - classic_sess->reset_for_map_change(); - } - classic_remote_disconnect_pending = false; - classic_auth_connect_failure_pending = false; - classic_auth_remote_disconnect_pending = false; - if (game) { - game->world().reset_for_map_change(); - } - emit_signal("world_reset"); - if (auth) { - auth->disconnect(); - auth.reset(); - } - if (game) { - game->disconnect(); - game.reset(); - } - if (classic_sess) { - classic_sess->disconnect(); - classic_sess.reset(); - } - if (mark) { - mark->disconnect(); - mark.reset(); - } - if (classic_mark) { - classic_mark->disconnect(); - classic_mark.reset(); - } - shop_open_seen = false; - mall_open_seen = false; - safebox_open_seen = false; - local_main_attacking = false; - item_validator_world = nullptr; - selected_pid = 0; - if (stage != Stage::Idle) { - set_stage(Stage::Idle); - } -} - -bool M2Client::select_character(int index) { - if (classic_sess) { - const bool ok = classic_sess->select_char(index); - if (ok && index >= 0 && index < (int)classic_sess->char_slots().size()) { - selected_pid = classic_sess->char_slots()[index].id; - } - return ok; - } - if (!game) { - return false; - } - const bool ok = game->select_character(index); - if (ok) { - for (const auto &slot : game->chars()) { - if (slot.index == index) { - selected_pid = slot.id; - break; - } - } - } - return ok; -} - -bool M2Client::enter_game(int index) { - if (classic_sess) { - const bool ok = classic_sess->connect_direct_enter(index); - if (ok && index >= 0 && index < static_cast(classic_sess->char_slots().size())) { - selected_pid = classic_sess->char_slots()[index].id; - } - return ok; - } - return select_character(index); -} - -bool M2Client::create_character(int slot, const godot::String &name, int job, int shape, - int con, int intel, int str, int dex) { - const std::string name_wire = std::string(name.utf8().get_data()); - const size_t name_limit = classic_sess ? mtnet::classic::CHARACTER_NAME_MAX_LEN : - mtnet::CHARACTER_NAME_MAX_LEN; - if (slot < 0 || slot >= mtnet::PLAYER_PER_ACCOUNT4 || name_wire.empty() || - !mtnet::protocol_text_fits(name_wire, name_limit, classic_sess != nullptr) || - !mtnet::bounds::u16(job) || - !mtnet::bounds::u8(shape) || - !mtnet::bounds::u8(con) || !mtnet::bounds::u8(intel) || !mtnet::bounds::u8(str) || - !mtnet::bounds::u8(dex)) { - return false; - } - if (classic_sess) { - return classic_sess->create_character(slot, name_wire, job, shape, - con, intel, str, dex); - } - if (!game) { - return false; - } - return game->create_character(slot, name_wire, job, shape, - con, intel, str, dex); -} - -bool M2Client::delete_character(int slot, const godot::String &private_code) { - if (classic_sess) { - return classic_sess->delete_character(slot, std::string(private_code.utf8().get_data())); - } - if (!game) { - return false; - } - return game->delete_character(slot, std::string(private_code.utf8().get_data())); -} - -bool M2Client::change_name(int slot, const godot::String &name) { - if (classic_sess) { - return classic_sess->change_name(slot, std::string(name.utf8().get_data())); - } - if (!game) { - return false; - } - return game->change_name(slot, std::string(name.utf8().get_data())); -} - -int M2Client::get_empire() const { - if (classic_sess) { - return (int)classic_sess->parser().empire(); - } - return game ? (int)game->empire() : 0; -} - -bool M2Client::select_empire(int empire) { - if (empire < 1 || empire > 3) { - return false; - } - if (classic_sess) { - return classic_sess->send_empire((uint8_t)empire); - } - return game && game->phase() == mtnet::PHASE_SELECT && game->send_empire((uint8_t)empire); -} - -int M2Client::get_slot_count() const { - if (classic_sess) { - return classic_sess->parser().slot_count() > 0 ? classic_sess->parser().slot_count() - : mtnet::classic::PLAYER_PER_ACCOUNT; - } - return game ? game->slot_count() : mtnet::PLAYER_PER_ACCOUNT4; -} - -godot::Array M2Client::build_char_list() const { - Array list; - if (classic_sess) { - const auto &slots = classic_sess->char_slots(); - for (int i = 0; i < (int)slots.size(); ++i) { - const auto &c = slots[i]; - Dictionary d; - d["index"] = i; - d["id"] = (int64_t)c.id; - d["name"] = String::utf8(c.name.c_str()); - d["job"] = (int)c.job; - d["level"] = (int)c.level; - d["x"] = c.x; - d["y"] = c.y; - d["guild_id"] = (int64_t)c.guild_id; - d["guild_name"] = String::utf8(c.guild_name.c_str()); - d["play_minutes"] = (int)c.play_minutes; - d["st"] = (int)c.st; - d["ht"] = (int)c.ht; - d["dx"] = (int)c.dx; - d["iq"] = (int)c.iq; - d["main_part"] = (int)c.main_part; - d["hair_part"] = (int)c.hair_part; - d["change_name"] = c.change_name; - list.push_back(d); - } - return list; - } - if (!game) return list; - for (const auto &c : game->chars()) { - Dictionary d; - d["index"] = c.index; - d["id"] = (int64_t)c.id; - d["name"] = String(c.name.c_str()); - d["job"] = c.job; - d["level"] = c.level; - d["x"] = c.x; - d["y"] = c.y; - d["guild_id"] = (int64_t)c.guild_id; - d["guild_name"] = String::utf8(c.guild_name.c_str()); - d["play_minutes"] = (int)c.play_minutes; - d["st"] = c.st; - d["ht"] = c.ht; - d["dx"] = c.dx; - d["iq"] = c.iq; - d["main_part"] = c.main_part; - d["hair_part"] = c.hair_part; - d["change_name"] = c.change_name; - list.push_back(d); - } - return list; -} - -// --- in-game intents --- - -bool M2Client::move(int func, int arg, double rot_deg, int x, int y) { - if (!is_in_game() || !mtnet::bounds::u8(func) || !mtnet::bounds::u8(arg) || - !valid_angle(rot_deg) || !mtnet::bounds::i32(x) || !mtnet::bounds::i32(y)) { - return false; - } - double r = std::fmod(rot_deg, 360.0); - if (r < 0.0) { - r += 360.0; - } - uint32_t t = (uint32_t)Time::get_singleton()->get_ticks_msec(); - bool sent = false; - if (classic_sess) { - sent = classic_sess->send_move((uint8_t)func, (uint8_t)arg, (float)r, (int32_t)x, - (int32_t)y, t); - } else if (game) { - uint8_t rot = (uint8_t)(r / 5.0); // client wire form: degrees / 5 - sent = game->send_move((uint8_t)func, (uint8_t)arg, rot, (int32_t)x, (int32_t)y, t); - } - if (sent && (func == mtnet::FUNC_ATTACK || func == mtnet::FUNC_COMBO || - func == mtnet::FUNC_MOB_SKILL || (func & mtnet::FUNC_SKILL) != 0)) { - local_main_attacking = true; - } else if (sent && func == mtnet::FUNC_WAIT) { - local_main_attacking = false; - } - return sent; -} - -bool M2Client::character_position(int position) { - if (!is_in_game() || position < 0 || position > 255) { - return false; - } - if (classic_sess) { - return classic_sess->send_character_position((uint8_t)position); - } - return game && game->send_character_position((uint8_t)position); -} - -bool M2Client::request_warp() { - if (classic_sess) { - return is_in_game() && classic_sess->send_request_warp(); - } - return game && is_in_game() && game->send_warp(); -} - -bool M2Client::fishing(double rot_deg) { - if (!is_in_game() || !valid_angle(rot_deg)) { - return false; - } - double r = std::fmod(rot_deg, 360.0); - if (r < 0.0) { - r += 360.0; - } - if (classic_sess) { - return classic_sess->send_fishing((uint8_t)(r / 5.0)); - } - if (!game) return false; - return game->send_fishing((uint8_t)(r / 5.0)); -} - -bool M2Client::request_dungeon() { - if (classic_sess) { - return is_in_game() && classic_sess->send_dungeon(); - } - return game && is_in_game() && game->send_dungeon(); -} - -bool M2Client::sync_positions(const Array &positions) { - if (!is_in_game() || positions.is_empty() || positions.size() > 16) { - return false; - } - std::vector classic_elements; - std::vector elements; - if (classic_sess) classic_elements.reserve(positions.size()); - else elements.reserve(positions.size()); - for (int i = 0; i < positions.size(); ++i) { - Variant value = positions[i]; - if (value.get_type() != Variant::DICTIONARY) { - return false; - } - Dictionary d = value; - if (!d.has("vid") || !d.has("x") || !d.has("y")) { - return false; - } - const Variant raw_vid = d["vid"]; - const Variant raw_x = d["x"]; - const Variant raw_y = d["y"]; - if (raw_vid.get_type() != Variant::INT || raw_x.get_type() != Variant::INT || - raw_y.get_type() != Variant::INT) { - return false; - } - const int64_t vid = (int64_t)raw_vid; - const int64_t x = (int64_t)raw_x; - const int64_t y = (int64_t)raw_y; - if (!mtnet::bounds::u32(vid) || !mtnet::bounds::i32(x) || !mtnet::bounds::i32(y)) { - return false; - } - if (classic_sess) classic_elements.push_back({(uint32_t)vid, (int32_t)x, (int32_t)y}); - else elements.push_back({(uint32_t)vid, (int32_t)x, (int32_t)y}); - } - if (classic_sess) return classic_sess->send_sync_positions(classic_elements); - if (!game) return false; - return game->send_sync_positions(elements); -} - -bool M2Client::attack(int motion, int victim_vid) { - if (!is_in_game() || !mtnet::bounds::u8(motion) || victim_vid <= 0 || - !mtnet::bounds::u32(victim_vid)) { - return false; - } - bool sent = false; - if (classic_sess) { - sent = classic_sess->send_attack((uint8_t)motion, (uint32_t)victim_vid); - } else if (game) { - sent = game->send_attack((uint8_t)motion, (uint32_t)victim_vid); - } - if (sent) { - local_main_attacking = true; - } - return sent; -} - -bool M2Client::set_target(int victim_vid) { - // VID 0 is the stock client's explicit "clear target" packet. - if (!is_in_game() || victim_vid < 0 || !mtnet::bounds::u32(victim_vid)) { - return false; - } - if (classic_sess) { - return classic_sess->send_target((uint32_t)victim_vid); - } - return game && game->send_target((uint32_t)victim_vid); -} - -bool M2Client::say(int type, const String &text) { - if (!is_in_game() || !mtnet::bounds::u8(type)) { - return false; - } - const std::string text_wire = std::string(text.utf8().get_data()); - if ((classic_sess && !mtnet::protocol_text_fits(text_wire, 511, true)) || - (!classic_sess && !mtnet::protocol_text_fits(text_wire, - 0xffffu - sizeof(mtnet::CGChat) - 1, false))) { - return false; - } - if (classic_sess) { - return classic_sess->send_chat((uint8_t)type, text_wire); - } - return game && game->send_chat((uint8_t)type, text_wire); -} - -bool M2Client::send_emoticon(int index) { - // Registration order from playersettingmodule.py. The reference client's - // SendEmoticon sends this token through SendChatPacket, while the received - // token is parsed back into the matching visual effect by the chat handler. - static constexpr const char *kTokens[] = { - "(sweat)", "(money)", "(happy)", "(like)", "(love)", "(angry)", - "(aha)", "(sad)", "(sorry)", "(!)", "(?)", "(fish)" - }; - constexpr int kTokenCount = sizeof(kTokens) / sizeof(kTokens[0]); - if (index < 0 || index >= kTokenCount || !say(0 /* CHAT_TYPE_TALKING */, String(kTokens[index]))) { - return false; - } - emit_signal("emoticon_requested", index); - return true; -} - -bool M2Client::whisper(const String &to, const String &text) { - const std::string to_wire = std::string(to.utf8().get_data()); - const std::string text_wire = std::string(text.utf8().get_data()); - if (classic_sess) { - return is_in_game() && !to_wire.empty() && - mtnet::protocol_text_fits(to_wire, mtnet::classic::CHARACTER_NAME_MAX_LEN, true) && - mtnet::protocol_text_fits(text_wire, 511, true) && - classic_sess->send_whisper(to_wire, text_wire); - } - if (!game || !is_in_game() || to_wire.empty() || - !mtnet::protocol_text_fits(to_wire, mtnet::CHARACTER_NAME_MAX_LEN, false) || - !mtnet::protocol_text_fits(text_wire, - 0xffffu - sizeof(mtnet::CGWhisper) - 1, false)) { - return false; - } - return game->send_whisper(to_wire, text_wire); -} - -bool M2Client::cast_skill(int motion_idx, double rot_deg, int x, int y, int arg) { - // FUNC_SKILL | motion_idx; do not silently mask an invalid caller value. - if (motion_idx < 0 || motion_idx > 0x7f || arg < 0 || arg > 0xff) { - return false; - } - return move(0x80 | motion_idx, arg, rot_deg, x, y); -} - -bool M2Client::use_skill(int skill_id, int target_vid) { - // §3.8 mod 3: signed params, range-checked before the uint32 wire cast. - // skill_id is a real skill index (0 < id < SKILL_MAX_NUM), not a free u32. - if (!is_in_game() || !mtnet::bounds::skill_index(skill_id) || target_vid < 0 || - !mtnet::bounds::u32(target_vid)) { - return false; - } - if (classic_sess) { - return classic_sess->send_use_skill((uint32_t)skill_id, (uint32_t)target_vid); - } - if (!game) { - return false; - } - if (target_vid > 0) { - uint32_t fly_target_vid = 0; - int32_t fly_x = 0, fly_y = 0; - const mtnet::Entity *target = game->world().get((uint32_t)target_vid); - if (target) { - fly_target_vid = target->vid; - fly_x = (int32_t)target->x; - fly_y = (int32_t)target->y; - } - // The legacy client deliberately falls back to a zero target packet - // when the VID is no longer in its character manager. - if (!game->send_fly_targeting(fly_target_vid, fly_x, fly_y)) { - return false; - } - } - return game->send_use_skill((uint32_t)skill_id, (uint32_t)target_vid); -} - -bool M2Client::shoot(int skill_id) { - if (!is_in_game() || skill_id < 0 || skill_id > 255) { - return false; - } - if (classic_sess) return classic_sess->send_shoot((uint8_t)skill_id); - if (!game) return false; - return game->send_shoot((uint8_t)skill_id); -} - -bool M2Client::fly_targeting(int target_vid, int x, int y) { - if (!is_in_game() || target_vid < 0 || !mtnet::bounds::u32(target_vid) || - !mtnet::bounds::i32(x) || !mtnet::bounds::i32(y)) { - return false; - } - if (classic_sess) return classic_sess->send_fly_targeting((uint32_t)target_vid, - (int32_t)x, (int32_t)y); - if (!game) return false; - return game->send_fly_targeting((uint32_t)target_vid, (int32_t)x, (int32_t)y); -} - -bool M2Client::add_fly_targeting(int target_vid, int x, int y) { - if (!is_in_game() || target_vid < 0 || !mtnet::bounds::u32(target_vid) || - !mtnet::bounds::i32(x) || !mtnet::bounds::i32(y)) { - return false; - } - if (classic_sess) return classic_sess->send_add_fly_targeting((uint32_t)target_vid, (int32_t)x, (int32_t)y); - if (!game) return false; - return game->send_add_fly_targeting((uint32_t)target_vid, (int32_t)x, (int32_t)y); -} - -bool M2Client::skill_up(int skill_id) { - // §3.9 / §3.10: /skillup 只发正的、低于 SKILL_MAX_NUM 的技能索引(与 use_skill 同一谓词)。 - if (!is_in_game() || !mtnet::bounds::skill_index(skill_id)) { - return false; - } - return say(0 /*CHAT_TYPE_TALKING*/, String("/skillup ") + String::num_int64(skill_id)); -} - -int M2Client::get_skill_group() const { - const mtnet::EntityStore *world = active_world(); - return world ? (int)world->skill_group() : 0; -} - -Array M2Client::get_skills() const { - Array out; - const mtnet::EntityStore *world = active_world(); - if (!world) { - return out; - } - for (int id = 0; id < mtnet::SKILL_MAX_NUM; ++id) { - int lv = world->skill_level(id); - if (lv > 0) { - Dictionary d; - d["id"] = id; - d["level"] = lv; - d["master"] = (int)world->skill_master(id); // 0..3 - out.push_back(d); - } - } - return out; -} - -Array M2Client::get_quickslots() const { - Array out; - const mtnet::EntityStore *world = active_world(); - if (!world) { - return out; - } - for (int pos = 0; pos < mtnet::QUICKSLOT_MAX_NUM; ++pos) { - mtnet::QuickSlot qs = world->quickslot(pos); - if (qs.type == 0) { - continue; - } - Dictionary d; - d["pos"] = pos; - d["type"] = (int)qs.type; // 1 inventory / 2 skill / 3 emotion / 4 shop (not executable) - d["ref"] = (int)qs.position; // item cell / skill id / ... - out.push_back(d); - } - return out; -} - -bool M2Client::quickslot_add(int pos, int type, int ref) { - if (!is_in_game() || !mtnet::bounds::quick_slot(pos) || !mtnet::bounds::u8(type) || - type < 1 || type > 3 || !mtnet::bounds::u8(ref)) { - return false; - } - if (classic_sess) return classic_sess->send_quickslot_add((uint8_t)pos, (uint8_t)type, (uint8_t)ref); - if (!game) return false; - return game->send_quickslot_add((uint8_t)pos, (uint8_t)type, (uint8_t)ref); -} - -bool M2Client::quickslot_del(int pos) { - if (!is_in_game() || !mtnet::bounds::quick_slot(pos)) { - return false; - } - if (classic_sess) return classic_sess->send_quickslot_del((uint8_t)pos); - if (!game) return false; - return game->send_quickslot_del((uint8_t)pos); -} - -bool M2Client::quickslot_swap(int pos, int change_pos) { - if (!is_in_game() || !mtnet::bounds::quick_slot(pos) || - !mtnet::bounds::quick_slot(change_pos)) { - return false; - } - if (classic_sess) return classic_sess->send_quickslot_swap((uint8_t)pos, (uint8_t)change_pos); - if (!game) return false; - return game->send_quickslot_swap((uint8_t)pos, (uint8_t)change_pos); -} - -bool M2Client::click_npc(int vid) { - if (!is_in_game() || vid <= 0 || !mtnet::bounds::u32(vid)) { - return false; - } - if (classic_sess) { - return classic_sess->send_click_npc((uint32_t)vid); - } - return game && game->send_on_click((uint32_t)vid); -} -bool M2Client::script_answer(int answer) { - if (!is_in_game() || !mtnet::bounds::u8(answer)) { - return false; - } - if (classic_sess) { - return classic_sess->send_script_answer((uint8_t)answer); - } - return game && game->send_script_answer((uint8_t)answer); -} -bool M2Client::script_button(int idx) { - if (!is_in_game() || !mtnet::bounds::script_button_index(idx)) { - return false; - } - if (classic_sess) return classic_sess->send_script_button((uint32_t)idx); - return game && game->send_script_button((uint32_t)idx); -} -bool M2Client::script_select_item(int selection) { - if (!is_in_game() || selection < 0 || !mtnet::bounds::u32(selection)) { - return false; - } - if (classic_sess) return classic_sess->send_script_select_item((uint32_t)selection); - if (!game) return false; - return game->send_script_select_item((uint32_t)selection); -} -bool M2Client::quest_input(const String &text) { - if (classic_sess) return is_in_game() && classic_sess->send_quest_input(std::string(text.utf8().get_data())); - return game && is_in_game() && game->send_quest_input(std::string(text.utf8().get_data())); -} -bool M2Client::quest_confirm(bool yes, int request_pid) { - if (classic_sess) return is_in_game() && request_pid >= 0 && mtnet::bounds::u32(request_pid) && - classic_sess->send_quest_confirm(yes, (uint32_t)request_pid); - return game && is_in_game() && request_pid >= 0 && mtnet::bounds::u32(request_pid) && - game->send_quest_confirm(yes, (uint32_t)request_pid); -} -bool M2Client::quest_cancel() { - if (classic_sess) return is_in_game() && classic_sess->send_quest_cancel(); - return game && is_in_game() && game->send_quest_cancel(); -} - -Array M2Client::get_quests() const { - Array out; - const mtnet::EntityStore *world = active_world(); - if (!world) { - return out; - } - for (uint16_t idx : world->quest_indices()) { - const mtnet::QuestInfo *q = world->quest(idx); - if (!q) { - continue; - } - Dictionary d; - d["index"] = (int)q->index; - d["begin"] = q->begin; - d["title"] = String::utf8(q->title.c_str()); - d["clock_name"] = String::utf8(q->clock_name.c_str()); - d["clock_value"] = q->clock_value; - d["clock_start_ms"] = (int64_t)q->clock_start_ms; - d["counter_name"] = String::utf8(q->counter_name.c_str()); - d["counter_value"] = q->counter_value; - d["icon"] = String::utf8(q->icon.c_str()); - out.push_back(d); - } - return out; -} - -// --- P8 social / shop / storage --- - -bool M2Client::party_invite(int vid) { - if (classic_sess) return is_in_game() && vid > 0 && mtnet::bounds::u32(vid) && - classic_sess->send_party_invite((uint32_t)vid); - return game && is_in_game() && vid > 0 && mtnet::bounds::u32(vid) && - game->send_party_invite((uint32_t)vid); -} -bool M2Client::party_answer(int leader_pid, bool accept) { - if (classic_sess) return is_in_game() && leader_pid > 0 && mtnet::bounds::u32(leader_pid) && - classic_sess->send_party_answer((uint32_t)leader_pid, accept); - return game && is_in_game() && leader_pid > 0 && mtnet::bounds::u32(leader_pid) && - game->send_party_invite_answer((uint32_t)leader_pid, accept); -} -bool M2Client::party_leave(int pid) { - if (classic_sess) return is_in_game() && pid > 0 && mtnet::bounds::u32(pid) && - classic_sess->send_party_remove((uint32_t)pid); - return game && is_in_game() && pid > 0 && mtnet::bounds::u32(pid) && - game->send_party_remove((uint32_t)pid); -} -bool M2Client::party_use_skill(int skill_index, int target_vid) { - if (!is_in_game() || !mtnet::bounds::u8(skill_index) || target_vid < 0 || - !mtnet::bounds::u32(target_vid)) { - return false; - } - if (classic_sess) return classic_sess->send_party_use_skill((uint8_t)skill_index, (uint32_t)target_vid); - if (!game) return false; - return game->send_party_use_skill((uint8_t)skill_index, (uint32_t)target_vid); -} -bool M2Client::party_set_distribute(int mode) { - if (classic_sess) return is_in_game() && mtnet::bounds::u8(mode) && - classic_sess->send_party_parameter((uint8_t)mode); - return game && is_in_game() && mtnet::bounds::u8(mode) && - game->send_party_parameter((uint8_t)mode); -} -bool M2Client::party_set_state(int pid, int role, bool on) { - if (!is_in_game() || pid <= 0 || !mtnet::bounds::u32(pid) || role < 0 || role >= 8) { - return false; - } - if (classic_sess) return classic_sess->send_party_set_state((uint32_t)pid, (uint8_t)role, on); - return game && game->send_party_set_state((uint32_t)pid, (uint8_t)role, on ? 1 : 0); -} -Array M2Client::get_party() const { - Array out; - const mtnet::EntityStore *world = active_world(); - if (!world) { - return out; - } - for (uint32_t pid : world->party_pids()) { - const mtnet::PartyMember *m = world->party_member(pid); - if (!m) { - continue; - } - Dictionary d; - d["pid"] = (int)m->pid; - d["vid"] = (int)m->vid; - d["name"] = String::utf8(m->name.c_str()); - d["leader"] = m->leader(); - d["state"] = (int)m->state; // full role byte (bit0 = leader on this fork's server) - d["hp_pct"] = (int)m->hp_pct; - Array affects; - for (int i = 0; i < mtnet::PARTY_AFFECT_SLOT_MAX_NUM; ++i) { - affects.push_back((int)m->affects[i]); - } - d["affects"] = affects; - out.push_back(d); - } - return out; -} -int M2Client::get_party_distribute_mode() const { - const mtnet::EntityStore *world = active_world(); - return world ? (int)world->party_distribute_mode() : 0; -} - -bool M2Client::add_friend(const String &name) { - if (classic_sess) { - return is_in_game() && classic_sess->send_friend_add(std::string(name.utf8().get_data())); - } - return game && is_in_game() && game->send_friend_add(std::string(name.utf8().get_data())); -} -bool M2Client::remove_friend(const String &name) { - if (classic_sess) { - return is_in_game() && classic_sess->send_friend_remove(std::string(name.utf8().get_data())); - } - return game && is_in_game() && game->send_friend_remove(std::string(name.utf8().get_data())); -} -bool M2Client::friend_answer(const String &name, bool accept) { - if (!is_in_game() || name.is_empty()) { - return false; - } - // 40250 handles messenger invitations through the normal slash-command - // channel: `/messenger_auth y|n `, not a CG subheader. - const std::string command = "/messenger_auth " + std::string(accept ? "y " : "n ") + - std::string(name.utf8().get_data()); - if (classic_sess) { - return classic_sess->send_chat(mtnet::CHAT_TYPE_TALKING, command); - } - return game && game->send_chat(mtnet::CHAT_TYPE_TALKING, command); -} -Array M2Client::get_friends() const { - Array out; - const mtnet::EntityStore *world = active_world(); - if (!world) { - return out; - } - for (const auto &f : world->friends()) { - Dictionary d; - d["name"] = String::utf8(f.name.c_str()); - d["online"] = f.online; - d["mobile"] = f.mobile; - out.push_back(d); - } - return out; -} - -Dictionary M2Client::get_lover() const { - Dictionary d; - const mtnet::EntityStore *world = active_world(); - if (!world) return d; - const mtnet::LoverInfo &lover = world->lover(); - d["valid"] = lover.valid; - d["name"] = String::utf8(lover.name.c_str()); - d["love_point"] = (int)lover.love_point; - d["online"] = lover.online; - d["near"] = lover.near; - return d; -} - -bool M2Client::shop_buy(int pos, int count) { - if (!is_in_game() || !mtnet::bounds::u8(pos) || count < 1 || !mtnet::bounds::u8(count)) { - return false; - } - if (classic_sess) return classic_sess->send_shop_buy((uint8_t)pos, (uint8_t)count); - return game && game->send_shop_buy((uint8_t)pos, (uint8_t)count); -} -bool M2Client::shop_sell(int inv_cell, int count) { - if (!is_in_game() || !mtnet::bounds::u8(inv_cell) || count < 0 || !mtnet::bounds::u8(count)) { - return false; - } - if (classic_sess) return classic_sess->send_shop_sell((uint8_t)inv_cell, (uint8_t)count); - return game->send_shop_sell((uint8_t)inv_cell, (uint8_t)count); -} -bool M2Client::shop_close() { - if (classic_sess) return is_in_game() && classic_sess->send_shop_close(); - return game && is_in_game() && game->send_shop_close(); -} -bool M2Client::is_shop_open() const { - const mtnet::EntityStore *world = active_world(); - return world && world->shop_open(); -} -static Dictionary shop_entry_dict(const mtnet::ShopEntry &s) { - Dictionary d; - d["pos"] = (int)s.pos; - d["vnum"] = (int)s.vnum; - d["price"] = (int)s.price; - d["count"] = (int)s.count; - Array sockets; - for (int k = 0; k < mtnet::ITEM_SOCKET_SLOT_MAX_NUM; ++k) { - sockets.push_back(s.sockets[k]); - } - d["sockets"] = sockets; - Array attrs; - for (int k = 0; k < mtnet::ITEM_ATTRIBUTE_SLOT_MAX_NUM; ++k) { - Dictionary ad; - ad["type"] = (int)s.attrs[k].type; - ad["value"] = (int)s.attrs[k].value; - attrs.push_back(ad); - } - d["attrs"] = attrs; - return d; -} - -Array M2Client::get_shop_items() const { - Array out; - const mtnet::EntityStore *world = active_world(); - if (!world) { - return out; - } - for (const auto &s : world->shop_items()) { - out.push_back(shop_entry_dict(s)); - } - return out; -} - -Dictionary M2Client::get_shop() const { - Dictionary d; - const mtnet::EntityStore *wp = active_world(); - if (!wp) { - return d; - } - const mtnet::EntityStore &w = *wp; - d["vid"] = (int)w.shop_vid(); - d["open"] = w.shop_open(); - d["selling_price"] = w.shop_selling_price(); - Array tabs; - if (!w.shop_tabs().empty()) { - // SHOP_GC_START_EX — one entry per shelf, each with its name + coin_type. - for (const auto &t : w.shop_tabs()) { - Dictionary td; - td["name"] = String::utf8(t.name.c_str()); - td["coin_type"] = (int)t.coin_type; - Array items; - for (const auto &s : t.items) { - items.push_back(shop_entry_dict(s)); - } - td["items"] = items; - tabs.push_back(td); - } - } else if (w.shop_open()) { - // plain SHOP_GC_START — a single unnamed tab - Dictionary td; - td["name"] = String(); - td["coin_type"] = 0; - td["items"] = get_shop_items(); - tabs.push_back(td); - } - d["tabs"] = tabs; - return d; -} - -bool M2Client::exchange_start(int vid) { - if (!is_in_game() || !mtnet::bounds::u32(vid) || vid <= 0) { - return false; - } - if (classic_sess) { - return classic_sess->send_exchange_start((uint32_t)vid); - } - return game && game->send_exchange_start((uint32_t)vid); -} -bool M2Client::exchange_add_item(int inv_window, int inv_cell, int display_pos) { - if (!is_in_game() || !valid_item_pos(inv_window, inv_cell) || - !mtnet::bounds::exchange_slot(display_pos)) { - return false; - } - if (classic_sess) { - return classic_sess->send_exchange_item_add((uint8_t)inv_window, (uint16_t)inv_cell, - (uint8_t)display_pos); - } - return game && game->send_exchange_item_add((uint8_t)inv_window, (uint16_t)inv_cell, - (uint8_t)display_pos); -} -bool M2Client::exchange_add_gold(int gold) { - if (!is_in_game() || !mtnet::bounds::u32(gold)) { - return false; - } - if (classic_sess) { - return classic_sess->send_exchange_gold((uint32_t)gold); - } - return game && game->send_exchange_gold((uint32_t)gold); -} -bool M2Client::exchange_accept() { - if (classic_sess) { - return is_in_game() && classic_sess->send_exchange_accept(); - } - return game && is_in_game() && game->send_exchange_accept(); -} -bool M2Client::exchange_cancel() { - if (classic_sess) { - return is_in_game() && classic_sess->send_exchange_cancel(); - } - return game && is_in_game() && game->send_exchange_cancel(); -} -Dictionary M2Client::get_exchange() const { - Dictionary d; - const mtnet::EntityStore *world = active_world(); - if (!world) { - return d; - } - const mtnet::ExchangeState &x = world->exchange(); - d["active"] = x.active; - d["partner_vid"] = (int)x.partner_vid; - d["self_gold"] = (int64_t)x.self_gold; - d["peer_gold"] = (int64_t)x.peer_gold; - d["self_accept"] = x.self_accept; - d["peer_accept"] = x.peer_accept; - // EXCHANGE_GC_ALREADY(6)/LESS_GOLD(7) 的子头值;0 = 无。成功/取消的区分 - // 只看 CHAT_TYPE_INFO 文案(服务器两种结局都发 GC_END)。 - d["notice"] = (int)x.last_notice; - auto slots = [](const mtnet::ExchangeSlot *arr) { - Array a; - for (int i = 0; i < 12; ++i) { - if (arr[i].vnum == 0) { - continue; - } - Dictionary s; - s["slot"] = i; - s["vnum"] = (int)arr[i].vnum; - s["count"] = (int)arr[i].count; - Array sockets; - for (int j = 0; j < mtnet::ITEM_SOCKET_SLOT_MAX_NUM; ++j) { - sockets.push_back(arr[i].sockets[j]); - } - s["sockets"] = sockets; - Array attrs; - for (int j = 0; j < mtnet::ITEM_ATTRIBUTE_SLOT_MAX_NUM; ++j) { - if (arr[i].attrs[j].type == 0) { - continue; - } - Dictionary a; - a["type"] = (int)arr[i].attrs[j].type; - a["value"] = (int)arr[i].attrs[j].value; - attrs.push_back(a); - } - s["attrs"] = attrs; - a.push_back(s); - } - return a; - }; - d["self_items"] = slots(x.self_items); - d["peer_items"] = slots(x.peer_items); - return d; -} - -bool M2Client::safebox_checkin(int safe_pos, int inv_window, int inv_cell) { - if (!is_in_game() || !mtnet::bounds::u8(safe_pos) || !valid_item_pos(inv_window, inv_cell)) { - return false; - } - if (classic_sess) { - return classic_sess->send_safebox_checkin((uint8_t)safe_pos, (uint8_t)inv_window, - (uint16_t)inv_cell); - } - return game && game->send_safebox_checkin((uint8_t)safe_pos, (uint8_t)inv_window, - (uint16_t)inv_cell); -} -bool M2Client::safebox_checkout(int safe_pos, int inv_window, int inv_cell) { - if (!is_in_game() || !mtnet::bounds::u8(safe_pos) || !valid_item_pos(inv_window, inv_cell)) { - return false; - } - if (classic_sess) { - return classic_sess->send_safebox_checkout((uint8_t)safe_pos, (uint8_t)inv_window, - (uint16_t)inv_cell); - } - return game && game->send_safebox_checkout((uint8_t)safe_pos, (uint8_t)inv_window, - (uint16_t)inv_cell); -} -bool M2Client::safebox_move(int from_cell, int to_cell, int count) { - if (!is_in_game() || !mtnet::bounds::u16(from_cell) || !mtnet::bounds::u16(to_cell) || - !mtnet::bounds::u8(count)) { - return false; - } - if (classic_sess) { - return classic_sess->send_safebox_move((uint16_t)from_cell, (uint16_t)to_cell, - (uint8_t)count); - } - return game && game->send_safebox_move((uint16_t)from_cell, (uint16_t)to_cell, (uint8_t)count); -} -bool M2Client::is_safebox_open() const { - const mtnet::EntityStore *world = active_world(); - return world && world->safebox_open(); -} -int M2Client::get_safebox_size() const { - const mtnet::EntityStore *world = active_world(); - return world ? world->safebox_size() : 0; -} -int M2Client::get_safebox_gold() const { - const mtnet::EntityStore *world = active_world(); - return world ? (int)world->safebox_gold() : 0; -} -static Dictionary item_dict(const mtnet::Item &it) { - Dictionary d; - d["vnum"] = (int)it.vnum; - d["count"] = (int)it.count; - d["flags"] = (int)it.flags; - d["anti_flags"] = (int)it.anti_flags; - Array sockets; - for (int i = 0; i < 3; ++i) { - sockets.push_back(it.sockets[i]); - } - d["sockets"] = sockets; - Array attrs; - for (int i = 0; i < 7; ++i) { - if (it.attrs[i].type == 0) { - continue; - } - Dictionary a; - a["type"] = (int)it.attrs[i].type; - a["value"] = (int)it.attrs[i].value; - attrs.push_back(a); - } - d["attrs"] = attrs; - return d; -} - -Array M2Client::get_safebox_items() const { - Array out; - const mtnet::EntityStore *world = active_world(); - if (!world) { - return out; - } - for (int c = 0; c < mtnet::SAFEBOX_MAX_NUM; ++c) { - const mtnet::Item &it = world->safebox_slot(c); - if (it.empty()) { - continue; - } - Dictionary d = item_dict(it); - d["cell"] = c; - out.push_back(d); - } - return out; -} - -// --- item-mall (창고몰) --- - -bool M2Client::is_mall_open() const { - const mtnet::EntityStore *world = active_world(); - return world && world->mall_open(); -} -int M2Client::get_mall_size() const { - const mtnet::EntityStore *world = active_world(); - return world ? world->mall_size() : 0; -} -Array M2Client::get_mall_items() const { - Array out; - const mtnet::EntityStore *world = active_world(); - if (!world) { - return out; - } - for (int c = 0; c < mtnet::MALL_MAX_NUM; ++c) { - const mtnet::Item &it = world->mall_slot(c); - if (it.empty()) { - continue; - } - Dictionary d = item_dict(it); - d["cell"] = c; - out.push_back(d); - } - return out; -} -bool M2Client::mall_checkout(int mall_pos, int inv_window, int inv_cell) { - if (!is_in_game() || !mtnet::bounds::u8(mall_pos) || !valid_item_pos(inv_window, inv_cell)) { - return false; - } - if (classic_sess) { - return classic_sess->send_mall_checkout((uint8_t)mall_pos, (uint8_t)inv_window, - (uint16_t)inv_cell); - } - return game && game->send_mall_checkout((uint8_t)mall_pos, (uint8_t)inv_window, - (uint16_t)inv_cell); -} - -bool M2Client::safebox_password(const String &password) { - if (password.is_empty() || password.length() > 6) { - return false; - } - return say(0, String("/safebox_password ") + password); -} - -bool M2Client::mall_password(const String &password) { - if (password.is_empty() || password.length() > 6) { - return false; - } - return say(0, String("/mall_password ") + password); -} - -bool M2Client::safebox_change_password(const String &old_password, const String &new_password) { - // 服务器 do_safebox_change_password 要求两个参数各 1..6 字符。 - if (old_password.is_empty() || old_password.length() > 6 || - new_password.is_empty() || new_password.length() > 6) { - return false; - } - return say(0, String("/safebox_change_password ") + old_password + " " + new_password); -} - -bool M2Client::safebox_close() { - return say(0, "/safebox_close"); -} - -bool M2Client::mall_close() { - return say(0, "/mall_close"); -} - -// --- private (PC) shop --- - -bool M2Client::open_private_shop(const godot::String &sign, const godot::Array &items) { - if (!is_in_game() || (!game && !classic_sess) || - items.size() > mtnet::bounds::PRIVATE_SHOP_ITEM_COUNT) { - return false; - } - const std::string sign_wire = std::string(sign.utf8().get_data()); - // The classic 40250 sign is a legacy-codepage field. Measure the encoded - // wire bytes here as well as in ClassicSession; otherwise a valid 16-CJK - // sign (32 GB2312 bytes, 48 UTF-8 bytes) is rejected before it reaches the - // protocol-specific sender. Dynamic map/quest/system text keeps its own - // packet capacity and is not routed through the 24-byte character-name rule. - const bool sign_too_long = !mtnet::protocol_text_fits(sign_wire, - classic_sess ? mtnet::classic::SHOP_SIGN_MAX_LEN : mtnet::SHOP_SIGN_MAX_LEN, - classic_sess != nullptr); - if (sign_wire.empty() || sign_too_long) { - return false; - } - - struct PrivateShopItemInput { - uint32_t vnum; - uint8_t count; - uint8_t inv_window; - uint16_t inv_cell; - uint32_t price; - uint8_t display_pos; - }; - std::vector parsed; - parsed.reserve(items.size()); - for (int i = 0; i < items.size(); ++i) { - const Variant raw = items[i]; - if (raw.get_type() != Variant::DICTIONARY) { - return false; - } - const Dictionary d = raw; - int64_t vnum = 0; - int64_t count = 1; - int64_t inv_window = mtnet::WINDOW_INVENTORY; - int64_t inv_cell = 0; - int64_t price = 0; - int64_t display_pos = i; - if (!read_int(d, "vnum", vnum, 0) || !read_int(d, "count", count, 1) || - !read_int(d, "inv_window", inv_window, mtnet::WINDOW_INVENTORY) || - !read_int(d, "inv_cell", inv_cell, 0) || !read_int(d, "price", price, 0) || - !read_int(d, "display_pos", display_pos, i) || !mtnet::bounds::u32(vnum) || - !mtnet::bounds::u8(count) || count < 1 || !mtnet::bounds::u8(inv_window) || - !mtnet::bounds::u16(inv_cell) || !mtnet::bounds::u32(price) || - display_pos < 0 || !mtnet::bounds::shop_slot(display_pos)) { - return false; - } - parsed.push_back({(uint32_t)vnum, (uint8_t)count, (uint8_t)inv_window, - (uint16_t)inv_cell, (uint32_t)price, (uint8_t)display_pos}); - } - - if (classic_sess) { - std::vector v; - v.reserve(parsed.size()); - for (const auto &in : parsed) { - mtnet::classic::CGMyShopItem e{}; - e.vnum = in.vnum; - e.count = in.count; - e.pos = {in.inv_window, in.inv_cell}; - e.price = in.price; - e.display_pos = in.display_pos; - v.push_back(e); - } - return classic_sess->send_private_shop(sign_wire, v); - } - std::vector v; - v.reserve(parsed.size()); - for (const auto &in : parsed) { - mtnet::MyShopItem e{}; - e.vnum = in.vnum; - e.count = in.count; - e.pos = {in.inv_window, in.inv_cell}; - e.price = in.price; - e.display_pos = in.display_pos; - v.push_back(e); - } - return game->send_open_private_shop(sign_wire, v); -} -bool M2Client::close_private_shop() { - if (classic_sess) { - // 40250 toggles a player's own shop in CInputMain::MyShop: receiving a - // CG_MYSHOP head while GetMyShop() is set closes it. CG_SHOP/END only - // stops an NPC-shop visit and leaves a private shop open. - return is_in_game() && classic_sess->send_private_shop("", {}); - } - return game && game->send_close_private_shop(); -} - -// --- cube (제작) --- - -Dictionary M2Client::get_cube() const { - Dictionary d; - const mtnet::EntityStore *world = active_world(); - if (!world) { - return d; - } - const mtnet::EntityStore::CubeState &c = world->cube(); - d["open"] = c.open; - d["npc_vnum"] = (int)c.npc_vnum; - d["need_gold"] = (int64_t)c.need_gold; - d["need_item_vnum"] = (int)c.need_item_vnum; - d["need_item_count"] = c.need_item_count; - Array recipes; - for (size_t ri = 0; ri < c.recipes.size(); ++ri) { - const auto &r = c.recipes[ri]; - Dictionary rd; - rd["index"] = (int)ri; - rd["result_vnum"] = (int)r.result_vnum; - rd["result_count"] = r.result_count; - rd["gold"] = (int64_t)r.gold; - Array groups; - for (const auto &g : r.material_groups) { - Array slots; - for (const auto &s : g) { - Dictionary sd; - sd["vnum"] = (int)s.vnum; - sd["count"] = s.count; - slots.push_back(sd); - } - groups.push_back(slots); - } - rd["material_groups"] = groups; - recipes.push_back(rd); - } - d["recipes"] = recipes; - Array results; - for (const auto &e : c.results) { - Dictionary ed; - ed["vnum"] = (int)e.vnum; - ed["count"] = e.count; - results.push_back(ed); - } - d["results"] = results; - return d; -} -bool M2Client::cube_make(int result_index) { - if (!is_in_game() || result_index < 0 || !mtnet::bounds::u32(result_index)) { - return false; - } - if (classic_sess) return classic_sess->send_cube_make(result_index); - return game && game->send_cube_make(result_index); -} -bool M2Client::cube_request_result_list(int npc_vnum) { - if (!is_in_game() || !mtnet::bounds::u32(npc_vnum)) { - return false; - } - if (classic_sess) return classic_sess->send_cube_result_list(npc_vnum); - return game && game->send_cube_result_list((uint32_t)npc_vnum); -} -bool M2Client::cube_request_materials(int start_index, int count) { - if (!is_in_game() || !mtnet::bounds::u8(start_index) || count < 1 || - !mtnet::bounds::u8(count)) { - return false; - } - if (classic_sess) return classic_sess->send_cube_materials(start_index, count); - return game && game->send_cube_material_info(start_index, count); -} -bool M2Client::cube_open() { - if (classic_sess) return is_in_game() && classic_sess->send_cube_open(); - return game && is_in_game() && game->send_chat(mtnet::CHAT_TYPE_COMMAND, "/cube open"); -} -bool M2Client::cube_close() { - if (classic_sess) return is_in_game() && classic_sess->send_cube_close(); - return game && is_in_game() && game->send_chat(mtnet::CHAT_TYPE_COMMAND, "/cube close"); -} -bool M2Client::cube_list() { - if (classic_sess) return is_in_game() && classic_sess->send_cube_list(); - return game && is_in_game() && game->send_chat(mtnet::CHAT_TYPE_COMMAND, "/cube list"); -} -bool M2Client::cube_add_item(int cube_index, int inventory_index) { - if (!is_in_game() || !mtnet::bounds::cube_slot(cube_index) || - !mtnet::bounds::u8(inventory_index)) { - return false; - } - if (classic_sess) return classic_sess->send_cube_add_item(cube_index, inventory_index); - if (!game) return false; - return game->send_chat(mtnet::CHAT_TYPE_COMMAND, "/cube add " + std::to_string(cube_index) + - " " + std::to_string(inventory_index)); -} -bool M2Client::cube_delete_item(int cube_index) { - if (!is_in_game() || !mtnet::bounds::cube_slot(cube_index)) return false; - if (classic_sess) return classic_sess->send_cube_delete_item(cube_index); - return game && game->send_chat(mtnet::CHAT_TYPE_COMMAND, "/cube delete " + std::to_string(cube_index)); -} - -// --- guild / refine --- - -Dictionary M2Client::get_guild() const { - Dictionary d; - const mtnet::EntityStore *world = active_world(); - if (!world) { - return d; - } - const mtnet::GuildState &g = world->guild(); - d["in_guild"] = g.in_guild; - d["id"] = (int)g.id; - d["name"] = String::utf8(g.name.c_str()); - d["level"] = (int)g.level; - d["exp"] = (int64_t)g.exp; - d["gold"] = (int64_t)g.gold; - d["member_count"] = (int)g.member_count; - d["max_member_count"] = (int)g.max_member_count; - d["master_pid"] = (int)g.master_pid; - d["has_land"] = g.has_land; - return d; -} -Array M2Client::get_guild_members() const { - Array out; - const mtnet::EntityStore *world = active_world(); - if (!world) { - return out; - } - for (const auto &m : world->guild_members()) { - Dictionary d; - d["pid"] = (int)m.pid; - d["name"] = String::utf8(m.name.c_str()); - d["grade"] = (int)m.grade; - d["job"] = (int)m.job; - d["level"] = (int)m.level; - d["offer"] = (int64_t)m.offer; - d["general"] = m.is_general; - d["online"] = m.online; - out.push_back(d); - } - return out; -} -Array M2Client::get_guild_grades() const { - Array out; - const mtnet::EntityStore *world = active_world(); - if (!world) { - return out; - } - for (int i = 0; i < 16; ++i) { - const mtnet::GuildGrade &g = world->guild_grade(i); - Dictionary d; - d["name"] = String::utf8(g.name.c_str()); - d["auth"] = (int)g.auth; - out.push_back(d); - } - return out; -} -bool M2Client::guild_add_member(int vid) { - if (classic_sess) return is_in_game() && vid > 0 && mtnet::bounds::u32(vid) && - classic_sess->send_guild_u32(mtnet::classic::GUILD_CG_ADD_MEMBER, (uint32_t)vid); - return game && is_in_game() && vid > 0 && mtnet::bounds::u32(vid) && - game->send_guild_sub_u32(mtnet::GUILD_CG_ADD_MEMBER, (uint32_t)vid); -} -bool M2Client::guild_remove_member(int pid) { - if (classic_sess) return is_in_game() && pid > 0 && mtnet::bounds::u32(pid) && - classic_sess->send_guild_u32(mtnet::classic::GUILD_CG_REMOVE_MEMBER, (uint32_t)pid); - return game && is_in_game() && pid > 0 && mtnet::bounds::u32(pid) && - game->send_guild_sub_u32(mtnet::GUILD_CG_REMOVE_MEMBER, (uint32_t)pid); -} -bool M2Client::guild_offer(int amount) { - if (classic_sess) return is_in_game() && mtnet::bounds::u32(amount) && - classic_sess->send_guild_u32(mtnet::classic::GUILD_CG_OFFER, (uint32_t)amount); - return game && is_in_game() && mtnet::bounds::u32(amount) && - game->send_guild_sub_u32(mtnet::GUILD_CG_OFFER, (uint32_t)amount); -} -bool M2Client::guild_deposit_money(int amount) { - // 40250 服务器禁用了存款处理(input_main.cpp GUILD_CG_DEPOSIT_MONEY 直接 - // return);原版 UI 仍发送该包,这里保持同样的线上行为。 - if (classic_sess) return is_in_game() && mtnet::bounds::u32(amount) && - classic_sess->send_guild_u32(mtnet::classic::GUILD_CG_DEPOSIT_MONEY, - (uint32_t)amount); - return game && is_in_game() && mtnet::bounds::u32(amount) && - game->send_guild_sub_u32(mtnet::GUILD_CG_DEPOSIT_MONEY, (uint32_t)amount); -} - -bool M2Client::guild_withdraw_money(int amount) { - // 同上:40250 服务器禁用取款(且原逻辑仅会长可取,上限 500000)。 - if (classic_sess) return is_in_game() && mtnet::bounds::u32(amount) && - classic_sess->send_guild_u32(mtnet::classic::GUILD_CG_WITHDRAW_MONEY, - (uint32_t)amount); - return game && is_in_game() && mtnet::bounds::u32(amount) && - game->send_guild_sub_u32(mtnet::GUILD_CG_WITHDRAW_MONEY, (uint32_t)amount); -} - -bool M2Client::guild_charge_gsp(int amount) { - if (!mtnet::bounds::i32(amount)) return false; - if (classic_sess) return is_in_game() && - classic_sess->send_guild_i32(mtnet::classic::GUILD_CG_CHARGE_GSP, (int32_t)amount); - return game && is_in_game() && - game->send_guild_sub_i32(mtnet::GUILD_CG_CHARGE_GSP, (int32_t)amount); -} -bool M2Client::guild_change_grade_name(int grade, const String &name) { - const std::string value = name.utf8().get_data(); - const bool name_too_long = !mtnet::protocol_text_fits(value, - mtnet::classic::GUILD_GRADE_NAME_MAX_LEN, classic_sess != nullptr); - if (grade < 1 || grade >= 16 || value.empty() || name_too_long) return false; - if (classic_sess) return is_in_game() && - classic_sess->send_guild_grade_name((uint8_t)grade, value); - return game && is_in_game() && game->send_guild_grade_name((uint8_t)grade, value); -} -bool M2Client::guild_change_grade_authority(int grade, int authority) { - if (grade < 1 || grade >= 16 || !mtnet::bounds::u8(authority)) return false; - if (classic_sess) return is_in_game() && - classic_sess->send_guild_grade_authority((uint8_t)grade, (uint8_t)authority); - return game && is_in_game() && - game->send_guild_grade_authority((uint8_t)grade, (uint8_t)authority); -} -bool M2Client::guild_change_member_grade(int pid, int grade) { - if (pid <= 0 || !mtnet::bounds::u32(pid) || grade < 1 || grade >= 16) return false; - if (classic_sess) return is_in_game() && - classic_sess->send_guild_member_grade((uint32_t)pid, (uint8_t)grade); - return game && is_in_game() && - game->send_guild_member_grade((uint32_t)pid, (uint8_t)grade); -} -bool M2Client::guild_change_member_general(int pid, bool enabled) { - if (pid <= 0 || !mtnet::bounds::u32(pid)) return false; - if (classic_sess) return is_in_game() && - classic_sess->send_guild_member_general((uint32_t)pid, enabled); - return game && is_in_game() && - game->send_guild_member_general((uint32_t)pid, enabled); -} -bool M2Client::guild_post_comment(const String &text) { - const std::string comment = text.utf8().get_data(); - const bool comment_too_long = !mtnet::protocol_text_fits(comment, - mtnet::classic::GUILD_COMMENT_MAX_LEN - 1, classic_sess != nullptr); - if (comment.empty() || comment_too_long) return false; - if (classic_sess) return is_in_game() && classic_sess->send_guild_comment(comment); - return game && is_in_game() && game->send_guild_comment(comment); -} -bool M2Client::guild_delete_comment(int comment_id) { - if (comment_id <= 0 || !mtnet::bounds::u32(comment_id)) return false; - if (classic_sess) return is_in_game() && - classic_sess->send_guild_u32(mtnet::classic::GUILD_CG_DELETE_COMMENT, - (uint32_t)comment_id); - return game && is_in_game() && - game->send_guild_sub_u32(mtnet::GUILD_CG_DELETE_COMMENT, (uint32_t)comment_id); -} -bool M2Client::guild_refresh_comments() { - if (classic_sess) return is_in_game() && - classic_sess->send_guild_sub(mtnet::classic::GUILD_CG_REFRESH_COMMENT); - return game && is_in_game() && game->send_guild_sub(mtnet::GUILD_CG_REFRESH_COMMENT); -} -bool M2Client::guild_answer_invite(int guild_id, bool accept) { - if (guild_id <= 0 || !mtnet::bounds::u32(guild_id)) return false; - if (classic_sess) return is_in_game() && - classic_sess->send_guild_invite_answer((uint32_t)guild_id, accept); - return game && is_in_game() && game->send_guild_invite_answer((uint32_t)guild_id, accept); -} -bool M2Client::guild_answer_make(const String &name) { - if (classic_sess) return is_in_game() && - classic_sess->send_guild_answer_make(std::string(name.utf8().get_data())); - return game && is_in_game() && - game->send_guild_answer_make(std::string(name.utf8().get_data())); -} -Array M2Client::get_guild_comments() const { - Array out; - const mtnet::EntityStore *world = active_world(); - if (!world) return out; - for (const auto &comment : world->guild_comments()) { - Dictionary d; - d["id"] = (int64_t)comment.id; - d["name"] = String::utf8(comment.name.c_str()); - d["content"] = String::utf8(comment.content.c_str()); - out.push_back(d); - } - return out; -} - -// --- guild war / guild skill --- - -Dictionary M2Client::get_guild_skill() const { - Dictionary d; - const mtnet::EntityStore *world = active_world(); - if (!world) { - return d; - } - const mtnet::GuildSkillState &s = world->guild_skill(); - d["valid"] = s.valid; - d["skill_point"] = (int)s.skill_point; - d["guild_point"] = (int)s.guild_point; - d["max_guild_point"] = (int)s.max_guild_point; - Array lv; - for (int i = 0; i < mtnet::GUILD_SKILL_MAX_NUM; ++i) { - lv.push_back((int)s.levels[i]); - } - d["levels"] = lv; - return d; -} - -Array M2Client::get_guild_wars() const { - Array out; - const mtnet::EntityStore *world = active_world(); - if (!world) { - return out; - } - const mtnet::EntityStore &w = *world; - for (const auto &p : w.guild_wars()) { - Dictionary d; - d["src"] = (int64_t)p.src; - d["dst"] = (int64_t)p.dst; - d["src_name"] = String::utf8(w.guild_name(p.src).c_str()); - d["dst_name"] = String::utf8(w.guild_name(p.dst).c_str()); - out.push_back(d); - } - return out; -} - -Dictionary M2Client::get_guild_war() const { - Dictionary d; - const mtnet::EntityStore *world = active_world(); - if (!world) { - return d; - } - const mtnet::GuildWarStatus &g = world->guild_war(); - d["opp_guild_id"] = (int64_t)g.opp_guild_id; - d["opp_name"] = String::utf8(world->guild_name(g.opp_guild_id).c_str()); - d["type"] = (int)g.type; - d["state"] = (int)g.state; - return d; -} - -String M2Client::get_guild_name(int guild_id) const { - const mtnet::EntityStore *world = active_world(); - return world ? String::utf8(world->guild_name((uint32_t)guild_id).c_str()) : String(); -} - -bool M2Client::use_guild_skill(int skill_vnum, int target_vid) { - if (!is_in_game() || !mtnet::bounds::u32(skill_vnum) || !mtnet::bounds::u32(target_vid)) { - return false; - } - if (classic_sess) return classic_sess->send_guild_skill((uint32_t)skill_vnum, (uint32_t)target_vid); - return game && game->send_guild_use_skill((uint32_t)skill_vnum, (uint32_t)target_vid); -} - -bool M2Client::declare_guild_war(const String &guild_name) { - return say(0 /*CHAT_TYPE_TALKING*/, String("/war ") + guild_name); -} - -// --- guild marks --- - -bool M2Client::download_guild_marks(const String &host, int port) { - if ((!game && !classic_sess) || port <= 0 || !mtnet::bounds::u16(port) || host.is_empty()) { - return false; - } - mark_host = host; - mark_port = port; - mark_ready = false; - if (mark) { - mark->disconnect(); - mark.reset(); - } - if (classic_mark) { - classic_mark->disconnect(); - classic_mark.reset(); - } - if (classic_sess) { - classic_mark = std::make_unique( - classic_sess->parser().handle(), classic_sess->parser().random_key()); - if (!classic_mark->connect(std::string(host.utf8().get_data()), (uint16_t)port)) { - UtilityFunctions::push_warning("classic guild-mark connect failed: ", - String(classic_mark->last_error().c_str())); - classic_mark.reset(); - return false; - } - return true; - } - const uint32_t handle = game->mark_handle(); - const uint32_t random_key = game->mark_random_key(); - mark = std::make_unique(handle, random_key); - if (!mark->connect(std::string(host.utf8().get_data()), (uint16_t)port)) { - UtilityFunctions::push_warning("guild-mark connect failed: ", - String(mark->last_error().c_str())); - mark.reset(); - return false; - } - return true; -} - -bool M2Client::download_guild_symbol(const String &host, int port, int guild_id) { - if ((!game && !classic_sess) || port <= 0 || !mtnet::bounds::u16(port) || host.is_empty() || - guild_id <= 0 || !mtnet::bounds::u32(guild_id)) { - return false; - } - symbol_guild_id = 0; - symbol_data.clear(); - mark_host = host; - mark_port = port; - if (mark) { - mark->disconnect(); - mark.reset(); - } - if (classic_mark) { - classic_mark->disconnect(); - classic_mark.reset(); - } - if (classic_sess) { - classic_mark = std::make_unique( - classic_sess->parser().handle(), classic_sess->parser().random_key()); - classic_mark->set_download_symbol((uint32_t)guild_id); - if (!classic_mark->connect(std::string(host.utf8().get_data()), (uint16_t)port)) { - UtilityFunctions::push_warning("classic guild-symbol download connect failed: ", - String(classic_mark->last_error().c_str())); - classic_mark.reset(); - return false; - } - return true; - } - const uint32_t handle = game->mark_handle(); - const uint32_t random_key = game->mark_random_key(); - mark = std::make_unique(handle, random_key); - mark->set_download_symbol((uint32_t)guild_id); - if (!mark->connect(std::string(host.utf8().get_data()), (uint16_t)port)) { - UtilityFunctions::push_warning("guild-symbol download connect failed: ", - String(mark->last_error().c_str())); - mark.reset(); - return false; - } - return true; -} - -PackedByteArray M2Client::get_guild_symbol() const { - PackedByteArray b; - if (!symbol_data.empty()) { - b.resize((int64_t)symbol_data.size()); - std::memcpy(b.ptrw(), symbol_data.data(), symbol_data.size()); - } - return b; -} - -bool M2Client::are_guild_marks_ready() const { - return mark_ready; -} - -Dictionary M2Client::get_mark_server() const { - Dictionary d; - d["host"] = mark_host; - d["port"] = mark_port; - return d; -} - -bool M2Client::upload_guild_mark(const String &host, int port, int guild_id, const Ref &img) { - if ((!game && !classic_sess) || port <= 0 || !mtnet::bounds::u16(port) || host.is_empty() || - guild_id <= 0 || !mtnet::bounds::u32(guild_id) || img.is_null()) { - return false; - } - Ref rgba = img; - if (rgba->get_width() != mtnet::GUILD_MARK_WIDTH || rgba->get_height() != mtnet::GUILD_MARK_HEIGHT || - rgba->get_format() != Image::FORMAT_RGBA8) { - rgba = img->duplicate(); - rgba->convert(Image::FORMAT_RGBA8); - if (rgba->get_width() != mtnet::GUILD_MARK_WIDTH || - rgba->get_height() != mtnet::GUILD_MARK_HEIGHT) { - rgba->resize(mtnet::GUILD_MARK_WIDTH, mtnet::GUILD_MARK_HEIGHT, Image::INTERPOLATE_BILINEAR); - } - } - PackedByteArray b = rgba->get_data(); - const int want = mtnet::GUILD_MARK_WIDTH * mtnet::GUILD_MARK_HEIGHT * 4; - if (b.size() != want) { - return false; - } - mark_host = host; - mark_port = port; - if (mark) { - mark->disconnect(); - mark.reset(); - } - if (classic_mark) { - classic_mark->disconnect(); - classic_mark.reset(); - } - if (classic_sess) { - classic_mark = std::make_unique( - classic_sess->parser().handle(), classic_sess->parser().random_key()); - classic_mark->set_upload_mark((uint32_t)guild_id, (const uint32_t *)b.ptr()); - if (!classic_mark->connect(std::string(host.utf8().get_data()), (uint16_t)port)) { - UtilityFunctions::push_warning("classic guild-mark upload connect failed: ", - String(classic_mark->last_error().c_str())); - classic_mark.reset(); - return false; - } - return true; - } - const uint32_t handle = game->mark_handle(); - const uint32_t random_key = game->mark_random_key(); - mark = std::make_unique(handle, random_key); - mark->set_upload_mark((uint32_t)guild_id, (const uint32_t *)b.ptr()); - if (!mark->connect(std::string(host.utf8().get_data()), (uint16_t)port)) { - UtilityFunctions::push_warning("guild-mark upload connect failed: ", - String(mark->last_error().c_str())); - mark.reset(); - return false; - } - return true; -} - -bool M2Client::upload_guild_symbol(const String &host, int port, int guild_id, - const PackedByteArray &file_bytes) { - if ((!game && !classic_sess) || port <= 0 || !mtnet::bounds::u16(port) || host.is_empty() || - guild_id <= 0 || !mtnet::bounds::u32(guild_id) || file_bytes.is_empty()) { - return false; - } - std::vector bytes(file_bytes.ptr(), file_bytes.ptr() + file_bytes.size()); - mark_host = host; - mark_port = port; - if (mark) { - mark->disconnect(); - mark.reset(); - } - if (classic_mark) { - classic_mark->disconnect(); - classic_mark.reset(); - } - if (classic_sess) { - classic_mark = std::make_unique( - classic_sess->parser().handle(), classic_sess->parser().random_key()); - classic_mark->set_upload_symbol((uint32_t)guild_id, std::move(bytes)); - if (!classic_mark->connect(std::string(host.utf8().get_data()), (uint16_t)port)) { - UtilityFunctions::push_warning("classic guild-symbol upload connect failed: ", - String(classic_mark->last_error().c_str())); - classic_mark.reset(); - return false; - } - return true; - } - const uint32_t handle = game->mark_handle(); - const uint32_t random_key = game->mark_random_key(); - mark = std::make_unique(handle, random_key); - mark->set_upload_symbol((uint32_t)guild_id, std::move(bytes)); - if (!mark->connect(std::string(host.utf8().get_data()), (uint16_t)port)) { - UtilityFunctions::push_warning("guild-symbol upload connect failed: ", - String(mark->last_error().c_str())); - mark.reset(); - return false; - } - return true; -} - -Dictionary M2Client::get_guild_mark(int guild_id) const { - Dictionary d; - d["found"] = false; - if (!mark_store) { - return d; - } - mtnet::MarkRect rc = mark_store->rect_of((uint32_t)guild_id); - d["found"] = rc.found; - d["img_idx"] = rc.img_idx; - d["x"] = rc.x; - d["y"] = rc.y; - d["w"] = rc.w; - d["h"] = rc.h; - return d; -} - -Ref M2Client::get_guild_mark_image(int guild_id) const { - Ref img; - if (!mark_store) { - return img; - } - std::vector px = mark_store->mark_pixels((uint32_t)guild_id); - if (px.empty()) { - return img; - } - PackedByteArray b; - b.resize((int64_t)px.size() * 4); - std::memcpy(b.ptrw(), px.data(), px.size() * 4); - img = Image::create_from_data(mtnet::GUILD_MARK_WIDTH, mtnet::GUILD_MARK_HEIGHT, false, - Image::FORMAT_RGBA8, b); - return img; -} - -void M2Client::pump_mark() { - mark->process(); - const bool offline = mark->state() == mtnet::NetStream::State::Offline; - - if (mark->mode() == mtnet::MarkClient::Mode::DownloadSymbol) { - if (mark->complete()) { - symbol_guild_id = mark->symbol_guild_id(); - symbol_data = mark->symbol_data(); - PackedByteArray b = get_guild_symbol(); - mark.reset(); - emit_signal("guild_symbol_ready", (int64_t)symbol_guild_id, b); - } else if (offline) { - mark.reset(); - } - return; - } - - if (mark->mode() != mtnet::MarkClient::Mode::Download) { - // upload: done once the packet has actually left our send buffer, or the - // server closed after we sent it. - if (mark->upload_done() || (offline && mark->upload_sent())) { - mark.reset(); - emit_signal("guild_mark_uploaded", true); - } else if (offline) { - mark.reset(); - emit_signal("guild_mark_uploaded", false); - } - return; - } - - if (mark->complete()) { - mark_store = std::make_unique(mark->marks()); - mark_ready = true; - size_t n = mark_store->mark_count(); - mark.reset(); - emit_signal("guild_marks_ready", (int)n); - return; - } - if (offline) { - // server closed before COMPLETE — keep whatever blocks arrived. - mark_store = std::make_unique(mark->marks()); - mark_ready = mark_store->mark_count() > 0; - mark.reset(); - emit_signal("guild_marks_ready", mark_ready ? (int)mark_store->mark_count() : 0); - } -} - -void M2Client::pump_classic_mark() { - classic_mark->process(); - const bool offline = classic_mark->state() == - mtnet::classic::ClassicStream::State::Offline; - - if (classic_mark->mode() == mtnet::classic::ClassicMarkClient::Mode::DownloadSymbol) { - if (classic_mark->complete()) { - symbol_guild_id = classic_mark->symbol_guild_id(); - symbol_data = classic_mark->symbol_data(); - PackedByteArray b = get_guild_symbol(); - classic_mark.reset(); - emit_signal("guild_symbol_ready", (int64_t)symbol_guild_id, b); - } else if (offline) { - classic_mark.reset(); - } - return; - } - - if (classic_mark->mode() != mtnet::classic::ClassicMarkClient::Mode::Download) { - if (classic_mark->upload_done() || (offline && classic_mark->upload_sent())) { - classic_mark.reset(); - emit_signal("guild_mark_uploaded", true); - } else if (offline) { - classic_mark.reset(); - emit_signal("guild_mark_uploaded", false); - } - return; - } - - if (classic_mark->complete()) { - mark_store = std::make_unique(classic_mark->marks()); - mark_ready = true; - size_t n = mark_store->mark_count(); - classic_mark.reset(); - emit_signal("guild_marks_ready", (int)n); - return; - } - if (offline) { - // Keep successfully received blocks if the stock mark server closes - // immediately after the final block instead of sending PHASE_CLOSE. - mark_store = std::make_unique(classic_mark->marks()); - mark_ready = mark_store->mark_count() > 0; - classic_mark.reset(); - emit_signal("guild_marks_ready", mark_ready ? (int)mark_store->mark_count() : 0); - } -} -bool M2Client::refine(int pos, int type) { - if (!is_in_game() || !mtnet::bounds::refine_request(pos, type, mtnet::INVENTORY_MAX_NUM)) { - return false; - } - if (classic_sess) return classic_sess->send_refine((uint8_t)pos, (uint8_t)type); - return game && game->send_refine((uint8_t)pos, (uint8_t)type); -} - -bool M2Client::ds_refine(int mode, const Array &cells) { - if (!is_in_game()) { - return false; - } - static const uint8_t kMode[3] = { - mtnet::DS_SUB_DO_UPGRADE, mtnet::DS_SUB_DO_IMPROVEMENT, mtnet::DS_SUB_DO_REFINE - }; - if (mode < 0 || mode > 2) { - return false; - } - if (classic_sess) { - mtnet::classic::ItemPos grid[mtnet::classic::DS_REFINE_GRID_MAX_NUM] = {}; - int n = (int)cells.size(); - if (n > mtnet::classic::DS_REFINE_GRID_MAX_NUM) return false; - for (int i = 0; i < n; ++i) { - int cell = (int)cells[i]; - if (cell < 0 || cell > 0xffff) return false; - grid[i] = {(uint8_t)mtnet::WINDOW_INVENTORY, (uint16_t)cell}; - } - return classic_sess->send_dragon_soul_refine(kMode[mode], grid, n); - } - if (!game) return false; - mtnet::ItemPos grid[mtnet::DS_REFINE_WINDOW_MAX_NUM] = {}; - int n = (int)cells.size(); - if (n > mtnet::DS_REFINE_WINDOW_MAX_NUM) return false; - for (int i = 0; i < n; ++i) { - int cell = (int)cells[i]; - if (cell < 0 || cell > 0xffff) return false; - grid[i].window_type = mtnet::WINDOW_INVENTORY; - grid[i].cell = (uint16_t)cell; - } - return game->send_ds_refine(kMode[mode], grid); -} - -Array M2Client::get_dragon_souls() const { - Array out; - const mtnet::EntityStore *world = active_world(); - if (!world) { - return out; - } - for (int c = 0; c < mtnet::DRAGON_SOUL_MAX_NUM; ++c) { - const mtnet::Item &it = world->dragon_soul_slot(c); - if (it.vnum == 0) { - continue; - } - Dictionary d; - d["cell"] = c; - d["vnum"] = (int)it.vnum; - d["count"] = (int)it.count; - out.push_back(d); - } - return out; -} - -// --- P9 world systems --- - -int M2Client::get_channel() const { - const auto *world = active_world(); - return world ? world->channel() : 0; -} -int64_t M2Client::get_server_time() const { - const auto *world = active_world(); - return world ? world->server_time() : 0; -} -Array M2Client::get_npc_marks() const { - Array out; - const auto *world = active_world(); - if (!world) { - return out; - } - for (const auto &m : world->npc_marks()) { - Dictionary d; - d["type"] = (int)m.type; - d["vnum"] = (int)m.vnum; - d["name"] = String::utf8(m.name.c_str()); - fmt::m2coord::Vec3 g = fmt::m2coord::position_to_godot(m.x, m.y, 0); - d["pos"] = Vector3(g.x, g.y, g.z); - out.push_back(d); - } - return out; -} -Array M2Client::get_land_areas() const { - Array out; - const auto *world = active_world(); - if (!world) return out; - for (const auto &a : world->land_areas()) { - Dictionary d; - d["id"] = (int)a.id; - d["guild_id"] = (int)a.guild_id; - d["x"] = a.x; - d["y"] = a.y; - d["width"] = a.width; - d["height"] = a.height; - out.push_back(d); - } - return out; -} -Array M2Client::get_observers() const { - Array out; - const auto *world = active_world(); - if (!world) return out; - for (const auto &o : world->observers()) { - Dictionary d; - d["vid"] = (int)o.vid; - fmt::m2coord::Vec3 g = fmt::m2coord::position_to_godot(o.x, o.y, 0); - d["pos"] = Vector3(g.x, g.y, g.z); - out.push_back(d); - } - return out; -} -bool M2Client::is_observer_mode() const { - const auto *world = active_world(); - return world && world->observer_mode(); -} -int M2Client::get_observer_count() const { - const auto *world = active_world(); - return world ? world->observer_count() : 0; -} -bool M2Client::has_mobile_flag() const { - const auto *world = active_world(); - return world && world->mobile_flag(); -} -bool M2Client::combo_skill_enabled() const { - const auto *world = active_world(); - return world && world->combo_skill_flag(); -} -Dictionary M2Client::get_stamina_state() const { - Dictionary d; - const auto *world = active_world(); - if (!world) return d; - d["consuming"] = world->stamina_consuming(); - d["per_sec"] = (int)world->stamina_per_sec(); - d["current"] = (int)world->current_stamina(); - return d; -} -Array M2Client::get_world_markers() const { - Array out; - const auto *world = active_world(); - if (!world) { - return out; - } - for (const auto &m : world->markers()) { - Dictionary d; - d["id"] = (int)m.id; - d["name"] = String::utf8(m.name.c_str()); - d["vid"] = (int)m.vid; - d["type"] = (int)m.type; - fmt::m2coord::Vec3 g = fmt::m2coord::position_to_godot(m.x, m.y, 0); - d["pos"] = Vector3(g.x, g.y, g.z); - out.push_back(d); - } - return out; -} - -// --- world snapshot --- - -static Dictionary entity_dict(const mtnet::Entity &e) { - Dictionary d; - d["vid"] = (int)e.vid; - d["race"] = (int)e.race; - d["ch_type"] = (int)e.ch_type; // normalized: 0 PC, 1 NPC, 2 MONSTER, ... - d["name"] = String::utf8(e.name.c_str()); - d["shop_sign"] = String::utf8(e.shop_sign.c_str()); - d["is_main"] = e.is_main; - d["func"] = (int)e.func; - d["func_arg"] = (int)e.func_arg; - d["position"] = (int)e.position; - d["walk_mode"] = (int)e.walk_mode; - d["moving_speed"] = (int)e.moving_speed; - d["attack_speed"] = (int)e.attack_speed; - d["fly_target_vid"] = (int)e.fly_target_vid; - d["fly_target_set"] = e.fly_target_set; - d["fly_target_cm"] = Vector3(e.fly_target_x, 0, e.fly_target_y); - d["moving"] = e.moving; - d["angle_deg"] = e.angle; - d["hp"] = e.hp; - d["max_hp"] = e.max_hp; - d["sp"] = e.sp; - d["max_sp"] = e.max_sp; - d["level"] = e.level; - d["dead"] = e.dead; - d["stunned"] = e.stunned; - d["knock_down"] = e.knock_down; - d["skill_active"] = e.skill_active; - d["skill_can_cancel"] = e.skill_can_cancel; - d["acting_emotion"] = e.acting_emotion; - d["tcp_state_enabled"] = e.tcp_state_enabled; - d["sync_push_serial"] = (int64_t)e.sync_push_serial; - d["sync_push_from_cm"] = Vector3(e.sync_push_from_x, e.sync_push_from_y, 0.0); - d["sync_push_large"] = e.sync_push_large; - d["sync_push_skill_active"] = e.sync_push_skill_active; - d["mount_vnum"] = (int)e.mount_vnum; - d["guild"] = e.guild; - d["alignment"] = (int)e.alignment; - d["pk_mode"] = (int)e.pk_mode; - d["empire"] = (int)e.empire; - d["affect_flags"] = (int64_t)e.affect_flags; - d["owner_vid"] = (int)e.owner_vid; - d["state_flags"] = (int)e.state_flags; - Array parts; - for (int i = 0; i < 4; ++i) { - parts.push_back((int)e.parts[i]); - } - d["parts"] = parts; - // server cm (Z-up) -> Godot metres (Y-up) - fmt::m2coord::Vec3 g = fmt::m2coord::position_to_godot(e.x, e.y, e.z); - d["pos"] = Vector3(g.x, g.y, g.z); - d["pos_cm"] = Vector3(e.x, e.y, e.z); - return d; -} - -const mtnet::EntityStore *M2Client::active_world() const { - if (classic_sess) { - return &classic_sess->world(); - } - return game ? &game->world() : nullptr; -} - -void M2Client::set_entity_motion_speed(int vid, double walk_cm_s, double run_cm_s) { - mtnet::EntityStore *w = classic_sess ? &classic_sess->world() : (game ? &game->world() : nullptr); - if (w) { - w->set_motion_speed((uint32_t)vid, (float)walk_cm_s, (float)run_cm_s); - } -} - -void M2Client::set_entity_network_state_gate(int vid, bool skill_active, - bool skill_can_cancel, bool acting_emotion, bool tcp_state_enabled) { - mtnet::EntityStore *w = classic_sess ? &classic_sess->world() : (game ? &game->world() : nullptr); - if (w) { - w->mut_network_state_gate((uint32_t)vid, skill_active, skill_can_cancel, - acting_emotion, tcp_state_enabled); - } -} - -void M2Client::set_entity_knockdown(int vid, bool on) { - mtnet::EntityStore *w = classic_sess ? &classic_sess->world() : (game ? &game->world() : nullptr); - if (w) { - w->mut_knockdown((uint32_t)vid, on); - } -} - -Dictionary M2Client::get_entity(int vid) const { - const mtnet::EntityStore *w = active_world(); - if (!w) { - return Dictionary(); - } - const mtnet::Entity *e = w->get((uint32_t)vid); - return e ? entity_dict(*e) : Dictionary(); -} - -Array M2Client::get_entities() const { - Array out; - const mtnet::EntityStore *w = active_world(); - if (!w) { - return out; - } - for (uint32_t vid : w->vids()) { - if (const mtnet::Entity *e = w->get(vid)) { - out.push_back(entity_dict(*e)); - } - } - return out; -} - -int M2Client::get_main_vid() const { - const mtnet::EntityStore *w = active_world(); - return w ? (int)w->main_vid() : 0; -} - -int M2Client::get_main_pid() const { - return (int)selected_pid; -} - -Dictionary M2Client::get_points() const { - Dictionary d; - const mtnet::EntityStore *w = active_world(); - if (!w) { - return d; - } - const mtnet::PlayerPoints &p = w->points(); - d["hp"] = p.hp(); - d["max_hp"] = p.max_hp(); - d["sp"] = p.sp(); - d["max_sp"] = p.max_sp(); - d["stamina"] = p.stamina(); - d["max_stamina"] = p.max_stamina(); - d["level"] = p.level(); - // exp values overflow int32 at high level on live servers — surface unsigned. - d["exp"] = (int64_t)(uint32_t)p.exp(); - d["next_exp"] = (int64_t)(uint32_t)p.next_exp(); - d["gold"] = p.gold(); - d["attack_speed"] = p.v[mtnet::POINT_ATT_SPEED]; - d["casting_speed"] = p.v[mtnet::POINT_CASTING_SPEED]; - d["bow_distance"] = p.v[mtnet::POINT_BOW_DISTANCE]; // __GetBowRange() 距离加成 - // §3.10 加号按钮门控:三个技能点来源分开取(uicharacter.py skillPageStatDict)。 - d["skill_active"] = p.v[mtnet::POINT_SKILL]; - d["skill_support"] = p.v[mtnet::POINT_SUB_SKILL]; - d["skill_horse"] = p.v[mtnet::POINT_HORSE_SKILL]; - d["energy"] = p.energy(); - d["energy_end_time"] = p.energy_end_time(); - Array values; - for (int i = 0; i < mtnet::POINT_MAX_NUM; ++i) { - values.push_back(p.v[i]); - } - d["points"] = values; - return d; -} - -Dictionary M2Client::get_target() const { - Dictionary d; - const mtnet::EntityStore *world = active_world(); - if (!world) { - return d; - } - d["vid"] = (int)world->target_vid(); - d["hp_percent"] = (int)world->target_hp_pct(); - return d; -} - -static Dictionary affect_dict(const mtnet::Affect &a) { - Dictionary d; - d["type"] = (int)a.type; - d["point_idx"] = (int)a.point_idx; - d["value"] = a.value; - d["flag"] = (int)a.flag; - d["duration"] = a.duration; - return d; -} - -Array M2Client::get_affects() const { - Array out; - const mtnet::EntityStore *world = active_world(); - if (!world) { - return out; - } - for (const auto &a : world->affects()) { - out.push_back(affect_dict(a)); - } - return out; -} - -// --- items --- - -bool M2Client::is_equipment_cell(int window, int cell) const { - if (cell < 0) { - return false; - } - // The native adapter uses raw wear indices for WINDOW_EQUIPMENT, while the - // classic GameType.h also accepts the unified global 90..121 wear range. - if (window == mtnet::WINDOW_EQUIPMENT) { - return cell < mtnet::WEAR_MAX_NUM || - (cell >= mtnet::EQUIPMENT_SLOT_START && - cell < mtnet::EQUIPMENT_SLOT_START + mtnet::EQUIPMENT_SLOT_RESERVED_MAX_NUM); - } - return window == mtnet::WINDOW_INVENTORY && - cell >= mtnet::EQUIPMENT_SLOT_START && - cell < mtnet::EQUIPMENT_SLOT_START + mtnet::EQUIPMENT_SLOT_RESERVED_MAX_NUM; -} - -bool M2Client::item_proto_type(uint32_t vnum, uint8_t &type) const { - if (!item_proto_id.is_valid()) { - return false; - } - Object *proto = ObjectDB::get_instance(item_proto_id); - if (!proto) { - return false; - } - const Variant raw = proto->call("item", (int)vnum); - if (raw.get_type() != Variant::DICTIONARY) { - return false; - } - const Dictionary data = raw; - const Variant type_value = data.get("type", -1); - if (type_value.get_type() != Variant::INT) { - return false; - } - const int type_int = (int)type_value; - if (type_int < 0 || type_int > 255) { - return false; - } - type = (uint8_t)type_int; - return true; -} - -String M2Client::item_action_sound_for_item(int vnum, bool drop) const { - if (vnum <= 0 || !item_proto_id.is_valid()) { - return String(); - } - Object *proto = ObjectDB::get_instance(item_proto_id); - if (!proto) { - return String(); - } - const Variant raw = proto->call("item", vnum); - if (raw.get_type() != Variant::DICTIONARY) { - return String(); - } - const Dictionary data = raw; - const Variant type_value = data.get("type", -1); - const Variant sub_value = data.get("sub_type", -1); - if (type_value.get_type() != Variant::INT || sub_value.get_type() != Variant::INT) { - return String(); - } - const int type_int = (int)type_value; - const int sub_int = (int)sub_value; - if (type_int < 0 || type_int > 255 || sub_int < 0 || sub_int > 255) { - return String(); - } - - const mtnet::item_action::ItemSoundType sound = drop - ? mtnet::item_action::drop_sound_type((uint8_t)type_int, (uint8_t)sub_int) - : mtnet::item_action::use_sound_type((uint8_t)type_int, (uint8_t)sub_int); - switch (sound) { - case mtnet::item_action::ItemSoundType::None: - return String(); - case mtnet::item_action::ItemSoundType::Armor: - return String("equip_metal_armor.wav"); - case mtnet::item_action::ItemSoundType::Weapon: - return String("equip_metal_weapon.wav"); - case mtnet::item_action::ItemSoundType::Bow: - return String("equip_bow.wav"); - case mtnet::item_action::ItemSoundType::Accessory: - return String("equip_ring_amulet.wav"); - case mtnet::item_action::ItemSoundType::Potion: - return String("eat_potion.wav"); - case mtnet::item_action::ItemSoundType::Portal: - return String("potal_scroll.wav"); - case mtnet::item_action::ItemSoundType::Default: - default: - return String("drop.wav"); - } -} - -void M2Client::emit_item_action_sound(int window, int cell, bool drop) { - const mtnet::EntityStore *world = active_world(); - if (!world || !valid_item_pos(window, cell)) { - return; - } - const mtnet::Item &item = world->item_slot((uint8_t)window, cell); - if (item.empty()) { - return; - } - const String sound = item_action_sound_for_item((int)item.vnum, drop); - if (!sound.is_empty()) { - emit_signal("item_action_sound", sound); - } -} - -bool M2Client::item_proto_known(uint32_t vnum) const { - if (!item_proto_id.is_valid()) { - return false; - } - Object *proto = ObjectDB::get_instance(item_proto_id); - if (!proto) { - return false; - } - const Variant raw = proto->call("item", (int)vnum); - return raw.get_type() == Variant::DICTIONARY && !Dictionary(raw).is_empty(); -} - -void M2Client::apply_item_proto_validator() { - mtnet::EntityStore *world = classic_sess ? &classic_sess->world() : - (game ? &game->world() : nullptr); - if (!world) { - return; - } - if (world == item_validator_world && item_proto_ready == item_validator_ready) { - return; - } - if (!item_proto_ready) { - world->set_item_vnum_validator({}); - item_validator_world = world; - item_validator_ready = false; - return; - } - world->set_item_vnum_validator([this](uint32_t vnum) { - return item_proto_known(vnum); - }); - item_validator_world = world; - item_validator_ready = true; -} - -bool M2Client::is_equip_item_in_slot(int window, int cell) const { - const mtnet::EntityStore *world = active_world(); - if (!world || !is_equipment_cell(window, cell)) { - return false; - } - const mtnet::Item &item = world->item_slot((uint8_t)window, cell); - if (item.empty()) { - return false; - } - uint8_t type = 0; - if (item_proto_type(item.vnum, type)) { - return mtnet::item_action::is_equipment_type(type); - } - // Before the shared proto node is installed, keep the old native gate for - // actual wear slots. This is fail-closed for action restrictions and is only - // a lifecycle fallback; once item_proto is available the type is authoritative. - return true; -} - -bool M2Client::item_action_allowed(bool equipment_touched) const { - const mtnet::EntityStore *world = active_world(); - if (!world) { - return false; - } - const uint32_t vid = world->main_vid(); - const mtnet::Entity *main = world->get(vid); - mtnet::item_action::Context context; - context.has_main = main != nullptr; - context.shop_open = world->shop_open(); - context.exchange_active = world->exchange().active; - if (main) { - context.dead = main->dead; - context.stunned = main->stunned; - context.knock_down = main->knock_down; - context.attacking = local_main_attacking; - const uint8_t func = main->func; - context.attacking = context.attacking || func == mtnet::FUNC_ATTACK || - func == mtnet::FUNC_COMBO || func == mtnet::FUNC_MOB_SKILL || - (func & mtnet::FUNC_SKILL) != 0; - } - return mtnet::item_action::can_act_main(context) && - mtnet::item_action::can_touch_equipment(context, equipment_touched); -} - -bool M2Client::move_item(int fw, int fc, int tw, int tc, int count) { - if (!is_in_game() || !valid_item_pos(fw, fc) || !valid_item_pos(tw, tc) || - !mtnet::bounds::u8(count)) { - return false; - } - if (!item_action_allowed(is_equip_item_in_slot(fw, fc))) { - // ClientVS22 consumes a blocked action after __CanActMainInstance; - // callers must not retry it as a transport failure and no packet is sent. - return true; - } - emit_item_action_sound(fw, fc, true); - if (classic_sess) { - return classic_sess->send_item_move((uint8_t)fw, (uint16_t)fc, (uint8_t)tw, (uint16_t)tc, - (uint8_t)count); - } - return game && game->send_item_move((uint8_t)fw, (uint16_t)fc, (uint8_t)tw, (uint16_t)tc, - (uint8_t)count); -} -bool M2Client::use_item(int window, int cell) { - if (!is_in_game() || !valid_item_pos(window, cell)) { - return false; - } - if (!item_action_allowed(is_equip_item_in_slot(window, cell))) { - return true; - } - emit_item_action_sound(window, cell, false); - if (classic_sess) { - return classic_sess->send_item_use((uint8_t)window, (uint16_t)cell); - } - return game && game->send_item_use((uint8_t)window, (uint16_t)cell); -} -bool M2Client::drop_item(int window, int cell, int gold) { - if (!is_in_game() || !valid_item_pos(window, cell) || !mtnet::bounds::u32(gold)) { - return false; - } - if (!item_action_allowed(false)) { - return true; - } - if (classic_sess) { - return classic_sess->send_item_drop((uint8_t)window, (uint16_t)cell, (uint32_t)gold, 0); - } - return game && game->send_item_drop((uint8_t)window, (uint16_t)cell, (uint32_t)gold); -} -bool M2Client::drop_item_count(int window, int cell, int gold, int count) { - if (!is_in_game() || !valid_item_pos(window, cell) || !mtnet::bounds::u32(gold) || - count < 1 || !mtnet::bounds::u8(count)) return false; - if (!item_action_allowed(false)) { - return true; - } - if (classic_sess) { - return classic_sess->send_item_drop((uint8_t)window, (uint16_t)cell, (uint32_t)gold, - (uint8_t)count); - } - return game && game->send_item_drop2((uint8_t)window, (uint16_t)cell, (uint32_t)gold, - (uint8_t)count); -} -bool M2Client::use_item_to_item(int sw, int sc, int tw, int tc) { - if (!is_in_game() || !valid_item_pos(sw, sc) || !valid_item_pos(tw, tc)) return false; - if (!item_action_allowed(false)) { - return true; - } - if (classic_sess) { - return classic_sess->send_item_use_to_item((uint8_t)sw, (uint16_t)sc, (uint8_t)tw, (uint16_t)tc); - } - if (!game) return false; - return game->send_item_use_to_item((uint8_t)sw, (uint16_t)sc, (uint8_t)tw, (uint16_t)tc); -} -bool M2Client::give_item(int target_vid, int window, int cell, int count) { - if (!is_in_game() || target_vid <= 0 || !mtnet::bounds::u32(target_vid) || - !valid_item_pos(window, cell) || count < 1 || !mtnet::bounds::u8(count)) return false; - if (classic_sess) { - return classic_sess->send_item_give((uint32_t)target_vid, (uint8_t)window, (uint16_t)cell, - (uint8_t)count); - } - if (!game) return false; - return game->send_give_item((uint32_t)target_vid, (uint8_t)window, (uint16_t)cell, (uint8_t)count); -} -bool M2Client::pickup_item(int ground_vid) { - if (!is_in_game() || ground_vid <= 0 || !mtnet::bounds::u32(ground_vid)) { - return false; - } - if (!item_action_allowed(false)) { - return true; - } - if (classic_sess) { - return classic_sess->send_item_pickup((uint32_t)ground_vid); - } - return game && game->send_item_pickup((uint32_t)ground_vid); -} - - -Array M2Client::get_inventory() const { - Array out; - const mtnet::EntityStore *world = active_world(); - if (!world) { - return out; - } - for (int c = 0; c < mtnet::INVENTORY_MAX_NUM; ++c) { - const mtnet::Item &it = world->inv_slot(c); - if (it.empty()) { - continue; - } - Dictionary d = item_dict(it); - d["cell"] = c; - out.push_back(d); - } - return out; -} - -Array M2Client::get_equipment() const { - Array out; - const mtnet::EntityStore *world = active_world(); - if (!world) { - return out; - } - for (int w = 0; w < mtnet::WEAR_MAX_NUM; ++w) { - Dictionary d = item_dict(world->equip_slot(w)); - d["wear"] = w; - out.push_back(d); - } - return out; -} - -Array M2Client::get_belt_inventory() const { - Array out; - const auto *world = active_world(); - if (!world) return out; - for (int c = 0; c < mtnet::BELT_INVENTORY_MAX_NUM; ++c) { - const mtnet::Item &it = world->belt_slot(c); - if (it.empty()) continue; - Dictionary d = item_dict(it); - d["cell"] = c; - out.push_back(d); - } - return out; -} - -Array M2Client::get_view_equipment(int vid) const { - Array out; - const auto *world = active_world(); - if (!world || vid <= 0) return out; - const mtnet::ViewedEquipment *view = world->viewed_equipment((uint32_t)vid); - if (!view) return out; - for (int wear = 0; wear < mtnet::VIEW_EQUIP_WEAR_MAX_NUM; ++wear) { - Dictionary d = item_dict(view->items[wear]); - d["wear"] = wear; - out.push_back(d); - } - return out; -} - -Dictionary M2Client::get_item(int window, int cell) const { - const mtnet::EntityStore *world = active_world(); - if (!world) { - return Dictionary(); - } - return item_dict(world->item_slot((uint8_t)window, cell)); -} - -Array M2Client::get_ground_items() const { - Array out; - const mtnet::EntityStore *world = active_world(); - if (!world) { - return out; - } - for (uint32_t vid : world->ground_vids()) { - const mtnet::GroundItem *g = world->ground(vid); - if (!g) { - continue; - } - Dictionary d; - d["vid"] = (int)g->vid; - d["vnum"] = (int)g->vnum; - d["owner"] = String::utf8(g->owner.c_str()); - fmt::m2coord::Vec3 gp = fmt::m2coord::position_to_godot(g->x, g->y, g->z); - d["pos"] = Vector3(gp.x, gp.y, gp.z); - out.push_back(d); - } - return out; -} - -Array M2Client::get_pvp_relations() const { - Array out; - const auto *world = active_world(); - if (!world) return out; - for (const auto &p : world->pvp_relations()) { - Dictionary d; - d["src_vid"] = (int)p.src_vid; - d["dst_vid"] = (int)p.dst_vid; - d["mode"] = (int)p.mode; - out.push_back(d); - } - return out; -} - -Dictionary M2Client::get_duel() const { - Dictionary out; - const auto *world = active_world(); - if (!world) return out; - Array opponents; - for (uint32_t vid : world->duel_opponents()) { - opponents.push_back((int)vid); - } - out["active"] = !world->duel_opponents().empty(); - out["cannot_attack"] = world->duel_cannot_attack(); - out["opponents"] = opponents; - return out; -} - -void M2Client::_process(double) { - if (Engine::get_singleton()->is_editor_hint()) { - return; - } - if (suspended) { - return; // backgrounded — don't touch the socket - } - if (classic_sess) { - pump_classic(); - } - if (auth) { - pump_auth(); - } - if (game) { - pump_game(); - } - if (mark) { - pump_mark(); - } - if (classic_mark) { - pump_classic_mark(); - } -} - -// §9.3: M2Client no longer listens for NOTIFICATION_APPLICATION_PAUSED/RESUMED. -// AppFlow owns the single AppLifecycle coordinator (equivalent to -// CPythonApplication); it drives suspend()/resume() below. Keeping a second -// listener here meant two entry points for the same lifecycle edge. - -void M2Client::suspend() { - if (suspended) { - return; - } - suspended = true; - emit_signal("suspended"); -} - -void M2Client::resume() { - if (!suspended) { - return; - } - suspended = false; - emit_signal("resumed"); - // The next _process() pumps the socket; if the OS dropped the TCP - // connection while backgrounded, pump_game()/pump_auth() will see it go - // Offline and emit `disconnected`. GDScript can then call reconnect(). -} - -bool M2Client::reconnect() { - if (!have_cfg) { - return false; - } - // A non-blocking reconnect remains in GameConnect/GameLogin until the - // replacement stream reaches a phase. Treat another UI retry during that - // window as an idempotent request instead of creating a second session. - if (stage == Stage::AuthConnect || stage == Stage::AuthWait || - stage == Stage::GameConnect || - (stage == Stage::GameLogin && !classic_remote_disconnect_pending)) { - return true; - } - if (classic_remote_disconnect_pending) { - classic_remote_disconnect_pending = false; - } - // Fast path: still holding a valid login_key -> skip the auth server, go - // straight back to the (last) game server. Falls back to full re-login if - // the game connect fails. - if (last_login_key != 0 && !cfg_game_host.is_empty()) { - warp_to_game_server(cfg_game_host, cfg_game_port); - // ClassicSession::connect_warp() retains the 40250 selected slot and - // login ticket. Do not fall back to a new session after that attempt: - // doing so would clear DirectEnter state and replay the character list, - // unlike CNetworkStream::Clear()->Connect() on the reference client. - if (classic_sess) { - return stage == Stage::GameConnect; - } - if (stage == Stage::GameConnect) { - return true; - } - } - if (cfg_pw.is_empty()) { - // Both the classic AccountConnector path and the m2dev AuthClient clear - // their network password after materializing CG_LOGIN/CG_LOGIN3. Do not - // rebuild a full login with an empty password; the caller must submit the - // login form again when no login key was issued. - return false; - } - connect_to_server(cfg_auth_host, cfg_auth_port, cfg_game_host, cfg_game_port, cfg_id, cfg_pw); - return stage != Stage::Failed && stage != Stage::Idle; -} - -// Tear down the current game connection and reconnect to `host:port` reusing the -// stored login_key (cross-server GC_WARP, or a fast reconnect). -void M2Client::warp_to_game_server(const String &host, int port) { - if (classic_sess) { - // GC_WARP and a post-disconnect retry share the 40250 transport - // operation: clear the old socket/buffers, then reconnect the same - // CPythonNetworkStream. Preserve the parser's selected slot/login key - // instead of constructing a replacement session and replaying SELECT. - if (last_login_key != 0 && classic_sess->selected_slot() >= 0) { - classic_sess->reset_for_map_change(); - emit_signal("world_reset"); - if (mark) { - mark->disconnect(); - mark.reset(); - } - if (classic_mark) { - classic_mark->disconnect(); - classic_mark.reset(); - } - game_host = host; - game_port = port; - classic_list_emitted = false; - classic_last_stage = -1; - classic_last_empire = -1; - shop_open_seen = false; - mall_open_seen = false; - safebox_open_seen = false; - dead_seen.clear(); - if (!classic_sess->connect_warp(std::string(host.utf8().get_data()), - (uint16_t)port)) { - // connect_warp keeps DirectEnter set. 40250 therefore routes a - // refused replacement socket through ClosePhase -> SetLoginPhase, - // not through OnRemoteDisconnect/disconnected. The native phase - // observer will expose the LoginPhase owner on the next pump. - return; - } - last_game_phase = -1; - set_stage(Stage::GameConnect); - return; - } - - classic_sess->disconnect(); - classic_sess.reset(); - if (mark) { - mark->disconnect(); - mark.reset(); - } - if (classic_mark) { - classic_mark->disconnect(); - classic_mark.reset(); - } - game_host = host; - game_port = port; - classic_list_emitted = false; - classic_last_stage = -1; - classic_last_empire = -1; - shop_open_seen = false; - mall_open_seen = false; - safebox_open_seen = false; - dead_seen.clear(); - classic_sess = std::make_unique(); - classic_sess->on_login_failure = [this](const std::string &reason) { - emit_signal("login_failed", String(reason.c_str())); - }; - classic_sess->on_connect_failure = [this](const std::string &reason) { - emit_signal("connect_failed", String(reason.c_str())); - }; - classic_sess->on_auth_connect_failure = [this](const std::string &reason) { - classic_auth_connect_failure_pending = true; - set_stage(Stage::AuthConnect); - emit_signal("connect_failed", String(reason.c_str())); - }; - classic_sess->on_auth_remote_disconnect = [this](const std::string &) { - classic_auth_remote_disconnect_pending = true; - set_stage(Stage::AuthConnect); - }; - classic_sess->on_unsupported_security = [this](uint8_t header) { - emit_signal("optional_security_required", static_cast(header), - String("unsupported optional security packet GC header ") + - String::num_int64(header)); - }; - classic_sess->on_remote_disconnect = [this](const std::string &reason) { - classic_remote_disconnect_pending = true; - // The phase-leave callback owns world_reset ordering; this observer only - // exposes the transport reason and keeps an explicit retry possible. - emit_signal("disconnected", String(reason.c_str())); - }; - bind_classic_loading_reset(); - classic_sess->set_wire_trace(OS::get_singleton()->get_environment("MT_NET_TRACE") == "1"); - bool connected = false; - if (last_login_key != 0) { - connected = classic_sess->connect_with_login_key(std::string(host.utf8().get_data()), - (uint16_t)port, std::string(cfg_id.utf8().get_data()), last_login_key); - } else { - connected = classic_sess->connect(std::string(cfg_auth_host.utf8().get_data()), - (uint16_t)cfg_auth_port, std::string(host.utf8().get_data()), (uint16_t)port, - std::string(cfg_id.utf8().get_data()), std::string(cfg_pw.utf8().get_data())); - } - if (!connected) { - emit_signal("login_failed", String(classic_sess->last_error().c_str())); - set_stage(Stage::Failed); - classic_sess.reset(); - return; - } - last_game_phase = -1; - set_stage(Stage::GameConnect); - return; - } - if (last_login_key == 0) { - return; - } - if (game) { - game->disconnect(); - game.reset(); - } - auth.reset(); - game_host = host; - game_port = port; - char_list_emitted = false; - shop_open_seen = false; - dead_seen.clear(); - game = std::make_unique(std::string(account_id.utf8().get_data()), - last_login_key); - game->set_auto_enter_game(false); - loading_since_ms = 0; - enter_game_sent = false; - if (!game->connect(std::string(host.utf8().get_data()), (uint16_t)port)) { - emit_signal("login_failed", String(game->last_error().c_str())); - set_stage(Stage::Failed); - game.reset(); - return; - } - last_game_phase = -1; - last_game_state = last_game_empire = -1; - set_stage(Stage::GameConnect); -} - -void M2Client::bind_classic_loading_reset() { - if (!classic_sess) { - return; - } - classic_sess->on_loading_phase = [this]() { - if (!classic_sess) { - return; - } - // 40250 SetLoadingPhase clears CPythonPlayer, flying/effect - // instances before the first replacement spawn packet. The explicit - // GC_WARP path already reset the world before reconnecting, so the - // empty-world guard avoids a second reset for the same warp. - auto &world = classic_sess->world(); - if (world.main_vid() == 0 && world.size() == 0) { - return; - } - classic_sess->reset_for_map_change(); - emit_signal("world_reset"); - }; - classic_sess->on_phase_close = [this]() { - // 40250 keeps RecvPhasePacket(PHASE_CLOSE) separate from - // OnRemoteDisconnect. ClosePhase -> SetLoginPhase changes the phase/UI - // owner; it is not a transport failure and must not arm the reconnect - // owner or emit the disconnected signal. The stage transition below - // exposes LoginPhase after the old phase-leave reset has completed. - }; - classic_sess->on_phase_leave = [this]() { - // ClassicSession has already run the phase-leave reset. Notify the - // host before LoginPhase observers run so presentation state follows the - // 40250 owner transition. - emit_signal("world_reset"); - }; - classic_sess->on_stage_change = [this](mtnet::INetSession::Stage s) { - if (!classic_sess || s != mtnet::INetSession::Stage::LoggingIn) { - return; - } - // A new SetLoginPhase begins a fresh character-list owner. This callback - // runs during the phase packet itself, so a Login->Select burst cannot be - // hidden by the previous classic_list_emitted latch. - classic_list_emitted = false; - classic_last_stage = -1; - if (stage == Stage::GameConnect || stage == Stage::InGame) { - set_stage(Stage::GameLogin); - } - }; -} - -void M2Client::pump_classic() { - using St = mtnet::INetSession::Stage; - - apply_item_proto_validator(); - classic_sess->set_now((uint32_t)Time::get_singleton()->get_ticks_msec()); - classic_sess->pump(); - // AccountConnector::OnRemoteDisconnect returns the auth owner to Offline. - // Keep that native phase semantics, but surface the transport failure to the - // Godot login owner and leave the host in a retryable state. Without this - // bridge the host would remain in GameLogin because no game socket exists to - // emit the normal disconnected callback. - if (classic_sess->stage() == mtnet::INetSession::Stage::Offline && - stage != Stage::Idle && stage != Stage::Failed) { - classic_last_stage = static_cast(classic_sess->stage()); - if (classic_auth_connect_failure_pending) { - // The auth OnConnectFailure callback already reported the transport - // error and retained the LoginWindow owner. Keep it retryable across - // subsequent pumps while the native stream remains Offline. - set_stage(Stage::AuthConnect); - } else if (classic_auth_remote_disconnect_pending) { - // AccountConnector::OnRemoteDisconnect is a transport owner reset, not - // an application login rejection. Leave LoginWindow retryable and quiet. - set_stage(Stage::AuthConnect); - } else { - emit_signal("login_failed", String(classic_sess->last_error().c_str())); - set_stage(Stage::Failed); - } - } - // 40250 clears the network-side password as soon as CG_LOGIN/CG_LOGIN3 is - // emitted. cfg_pw is only the host's retry cache; discard it once the - // classic session has crossed that same boundary. Reconnect then uses the - // login key, or requires a fresh explicit login if no key was issued. - if (classic_sess->login_secret_cleared()) { - cfg_pw = String(); - } - if (classic_sess->login_key() != 0) { - last_login_key = classic_sess->login_key(); - } - const int empire = (int)classic_sess->parser().empire(); - if (empire != classic_last_empire) { - classic_last_empire = empire; - emit_signal("empire_changed", empire); - } - - // stage -> phase_changed / stage_changed / char_list / entered_game - int st = (int)classic_sess->stage(); - if (st != classic_last_stage) { - classic_last_stage = st; - static const char *pn[] = {"close", "handshake", "login", "select", - "loading", "in_game", "failed"}; - int idx = 0; - switch (classic_sess->stage()) { - case St::Offline: idx = 0; break; - case St::Connecting: idx = 1; break; - case St::LoggingIn: idx = 2; break; - case St::CharSelect: idx = 3; break; - case St::Loading: idx = 4; break; - case St::InGame: idx = 5; break; - case St::Failed: idx = 6; break; - } - emit_signal("phase_changed", String(pn[idx])); - if (classic_sess->stage() == St::Failed && stage != Stage::Failed) { - if (classic_sess->phase_closed()) { - // 40250 RecvPhasePacket(PHASE_CLOSE) calls ClosePhase, returning - // control to the login/offline owner. This is still a phase change, - // even when the replacement LoginPhase send fails; report that - // failure through the login callback rather than as a peer close. - emit_signal("login_failed", String(classic_sess->last_error().c_str())); - set_stage(Stage::Idle); - } else { - // ClientVS22 sends every already-established game connection loss back - // through SetLoginPhase(), regardless of whether SELECT was reached. - // Authentication failures happen before this flag is set and remain - // login_failed events. - if (classic_sess->was_online_lost()) { - emit_signal("disconnected", String(classic_sess->last_error().c_str())); - } else { - emit_signal("login_failed", String(classic_sess->last_error().c_str())); - } - set_stage(Stage::Failed); - } - } else if (classic_sess->stage() == St::LoggingIn && - (stage == Stage::GameConnect || stage == Stage::InGame)) { - set_stage(Stage::GameLogin); - } - } - - if (!classic_list_emitted && classic_sess->parser().char_list_ready() && - classic_sess->stage() == St::CharSelect) { - classic_list_emitted = true; - emit_signal("char_list", build_char_list()); - } - for (const auto &ev : classic_sess->parser().drain_char_events()) { - using CE = mtnet::classic::ClassicParser::CharEvent; - switch (ev.kind) { - case CE::CreateOk: - emit_signal("char_list", build_char_list()); - emit_signal("char_created", ev.slot); - break; - case CE::CreateFail: - emit_signal("char_create_failed", ev.fail_type); - break; - case CE::DeleteOk: - emit_signal("char_list", build_char_list()); - emit_signal("char_deleted", ev.slot); - break; - case CE::DeleteFail: - emit_signal("char_delete_failed"); - break; - } - } - for (const auto &ev : classic_sess->parser().drain_name_events()) { - emit_signal("char_list", build_char_list()); - emit_signal("char_name_changed", (int64_t)ev.pid, String::utf8(ev.name.c_str())); - } - for (int i = 0, n = classic_sess->parser().drain_guild_make_requests(); i < n; ++i) { - emit_signal("guild_make_requested"); - } - - if (classic_sess->in_game() && stage != Stage::InGame) { - set_stage(Stage::InGame); - emit_signal("entered_game"); - } - - if (classic_sess->in_game()) { - mtnet::EntityStore &w = classic_sess->world(); - // set_now()/tick() already run inside pump(); mirror the deltas the - // classic parser currently produces (grows with classic_parser). - for (const auto &c : w.drain_changes()) { - switch (c.kind) { - case mtnet::EntityStore::ChangeKind::Spawn: - if (const mtnet::Entity *e = w.get(c.vid)) { - emit_signal("entity_spawned", entity_dict(*e)); - } - break; - case mtnet::EntityStore::ChangeKind::Despawn: - dead_seen.erase(c.vid); - emit_signal("entity_despawned", (int)c.vid); - break; - case mtnet::EntityStore::ChangeKind::Move: - emit_signal("entity_moved", (int)c.vid); - break; - case mtnet::EntityStore::ChangeKind::MainSet: - emit_signal("entity_main_set", (int)c.vid); - break; - case mtnet::EntityStore::ChangeKind::Info: - if (const mtnet::Entity *e = w.get(c.vid)) { - emit_signal("entity_info", (int)c.vid, entity_dict(*e)); - } - break; - } - } - // §2.5: bare field updates (ownership / affect flags) with no Spawn/Move/ - // Info change — re-mirror the node so presentation stays in sync. - for (uint32_t vid : w.drain_dirty()) { - if (const mtnet::Entity *e = w.get(vid)) { - emit_signal("entity_info", (int)vid, entity_dict(*e)); - } - } - // §9.1: map background music. - if (w.take_bgm_dirty()) { - emit_signal("bgm_changed", String::utf8(w.bgm_name().c_str()), w.bgm_volume()); - } - for (const auto &m : w.drain_chat()) { - if (m.type == mtnet::CHAT_TYPE_WHISPER) { - emit_signal("whisper_received", (int)m.sub, String::utf8(m.from.c_str()), - String::utf8(m.text.c_str())); - } else { - emit_signal("chat", (int)m.type, (int)m.vid, String::utf8(m.text.c_str())); - } - } - // 40250 RecvPointChange order: ShowPointEffect(type, vid) is dispatched - // before the aggregate point/status refresh. Preserve that packet order - // for both protocol backends by draining the typed queue first. - for (const auto &pc : w.drain_point_changes()) { - emit_signal("point_changed", (int)pc.vid, (int)pc.type, (int)pc.value, - (int)pc.amount); - } - for (uint32_t vid : w.drain_vitals()) { - emit_signal("vitals_changed", (int)vid); - const mtnet::Entity *e = w.get(vid); - if (!e || !e->dead) { - dead_seen.erase(vid); - } - if (e && e->dead && !dead_seen.count(vid)) { - if (vid == w.main_vid()) { - local_main_attacking = false; - } - dead_seen.insert(vid); - emit_signal("entity_dead", (int)vid); - } - } - if (w.take_points_dirty()) { - Dictionary points = get_points(); - points["exp_gain"] = w.take_exp_gain(); - emit_signal("points_changed", points); - } - for (const auto &ih : w.drain_item_highlights()) { - emit_signal("item_highlighted", (int)ih.window, (int)ih.cell); - } - for (const auto &ic : w.drain_inv()) { - Dictionary item = item_dict(ic.item); - item["cell"] = (int)ic.cell; - item["window"] = (int)ic.window; - emit_signal("inventory_state_changed", (int)ic.window, (int)ic.cell, item); - emit_signal("inventory_changed", (int)ic.window, (int)ic.cell); - } - for (const auto &gc : w.drain_ground()) { - if (gc.added) { - const mtnet::GroundItem *g = w.ground(gc.vid); - if (g) { - Dictionary d; - d["vid"] = (int)g->vid; - d["vnum"] = (int)g->vnum; - d["owner"] = String::utf8(g->owner.c_str()); - fmt::m2coord::Vec3 gp = fmt::m2coord::position_to_godot(g->x, g->y, g->z); - d["pos"] = Vector3(gp.x, gp.y, gp.z); - emit_signal("ground_item_added", d); - } - } else { - emit_signal("ground_item_removed", (int)gc.vid); - } - } - for (const auto &p : w.drain_pvp_changes()) { - emit_signal("pvp_changed", (int)p.src_vid, (int)p.dst_vid, (int)p.mode); - } - if (w.take_duel_started()) { - emit_signal("duel_changed", get_duel()); - emit_signal("duel_started"); - } - for (const auto &mo : w.drain_motions()) { - emit_signal("motion", (int)mo.vid, (int)mo.victim_vid, (int)mo.motion); - } - for (const auto &dm : w.drain_dig_motions()) { - emit_signal("dig_motion", (int)dm.vid, (int)dm.target_vid, (int)dm.count); - } - for (const auto &fe : w.drain_fishing_events()) { - emit_signal("fishing_event", (int)fe.subheader, (int)fe.info, (int)fe.dir); - } - for (const auto &de : w.drain_dungeon_events()) { - emit_signal("dungeon_event", (int)de.subheader, (int)de.x, (int)de.y, - de.has_destination); - } - for (const auto &oe : w.drain_observer_events()) { - fmt::m2coord::Vec3 g = fmt::m2coord::position_to_godot(oe.x, oe.y, 0); - emit_signal("observer_event", (int)oe.kind, (int)oe.vid, Vector2(g.x, g.z)); - } - for (const auto &ec : w.drain_effect_cues()) { - emit_signal("effect_cue", (int)ec.vid, String::utf8(ec.file.c_str()), - (int)ec.special); - } - for (const auto &fc : w.drain_fly_cues()) { - emit_signal("fly_cue", (int)fc.type, (int)fc.start_vid, (int)fc.end_vid); - } - for (const auto &tc : w.drain_fly_target_cues()) { - emit_signal("fly_targeting", (int)tc.shooter_vid, (int)tc.target_vid, - Vector2((float)tc.x, (float)tc.y), tc.append); - } - for (const auto &d : w.drain_damage()) { - emit_signal("damage", (int)d.vid, (int)d.amount, (int)d.flag); - } - for (uint32_t vid : w.drain_mount_changes()) { - emit_signal("mount_changed", (int)vid); - } - if (w.take_target_dirty()) { - emit_signal("target_info", (int)w.target_vid(), (int)w.target_hp_pct()); - } - if (w.skills_dirty()) { - emit_signal("skills_changed"); - } - if (w.skill_group_dirty()) { - emit_signal("skill_group_changed", (int)w.skill_group()); - } - for (int sk : w.drain_cooldown_ends()) { - emit_signal("skill_cooldown_end", sk); - } - if (w.quickslots_dirty()) { - emit_signal("quickslots_changed"); - } - for (const auto &ac : w.drain_affects()) { - if (ac.added) { - for (const auto &a : w.affects()) { - if (a.type == ac.type) { - emit_signal("affect_added", affect_dict(a)); - break; - } - } - } else { - emit_signal("affect_removed", (int)ac.type); - } - } - for (const auto &ie : w.drain_item_events()) { - if (ie.kind == mtnet::ItemEvent::Get) { - emit_signal("item_picked_up", (int)ie.vnum, (int)ie.count, - String::utf8(ie.from.c_str())); - } else { - emit_signal("item_used", (int)ie.vnum); - } - } - for (uint32_t leader : w.drain_party_invites()) { - emit_signal("party_invite_ask", (int)leader); - } - for (const auto &ev : w.drain_server_commands()) { - switch (ev.kind) { - case mtnet::ServerCommandEvent::ServerQuitRequested: - emit_signal("server_quit_requested"); - break; - case mtnet::ServerCommandEvent::PartyRequestDenied: - emit_signal("party_request_denied"); - break; - case mtnet::ServerCommandEvent::LoverLogin: - case mtnet::ServerCommandEvent::LoverLogout: - case mtnet::ServerCommandEvent::LoverNear: - case mtnet::ServerCommandEvent::LoverFar: - case mtnet::ServerCommandEvent::LoverDivorce: - // LoverInfo 由 mutator 同步更新;lover_changed 信号在 - // take_lover_dirty() 处统一发出,这里无需单独 emit。 - break; - case mtnet::ServerCommandEvent::SafeboxPasswordRequired: - emit_signal("safebox_password_required"); - break; - case mtnet::ServerCommandEvent::SafeboxWrongPassword: - emit_signal("safebox_wrong_password"); - break; - case mtnet::ServerCommandEvent::MallPasswordRequired: - emit_signal("mall_password_required"); - break; - case mtnet::ServerCommandEvent::RefineSucceeded: - emit_signal("refine_result", true); - break; - case mtnet::ServerCommandEvent::RefineFailed: - emit_signal("refine_result", false); - break; - case mtnet::ServerCommandEvent::PrivateShopOpenRequested: - emit_signal("private_shop_open_requested"); - break; - case mtnet::ServerCommandEvent::MyShopPrice: - emit_signal("my_shop_price_list", (int)ev.value, (int)ev.value2); - break; - case mtnet::ServerCommandEvent::BlockModeChanged: - emit_signal("block_mode_changed", (int)ev.value); - break; - case mtnet::ServerCommandEvent::ObserverModeChanged: - emit_signal("observer_mode_changed", ev.value != 0); - break; - case mtnet::ServerCommandEvent::ObserverCountChanged: - emit_signal("observer_count_changed", (int)ev.value); - break; - case mtnet::ServerCommandEvent::StoneDetected: - emit_signal("stone_detected", (int)ev.value, (int)ev.value2, ev.value3); - break; - case mtnet::ServerCommandEvent::StaminaStarted: - emit_signal("stamina_changed", (int)ev.value2, (int)ev.value, true); - break; - case mtnet::ServerCommandEvent::StaminaStopped: - emit_signal("stamina_changed", (int)ev.value2, 0, false); - break; - case mtnet::ServerCommandEvent::MobileFlagChanged: - emit_signal("mobile_flag_changed", ev.value != 0); - break; - case mtnet::ServerCommandEvent::MobileAuthRequired: - emit_signal("mobile_auth_required"); - break; - case mtnet::ServerCommandEvent::ComboChanged: - emit_signal("combo_changed", ev.value != 0); - break; - case mtnet::ServerCommandEvent::GiftAvailable: - emit_signal("gift_available"); - break; - case mtnet::ServerCommandEvent::WeatherChanged: - emit_signal("weather_changed", ev.value != 0); - break; - } - } - if (w.party_dirty()) { - emit_signal("party_changed"); - } - bool shop_was_open = shop_open_seen; - if (w.shop_dirty()) { - shop_open_seen = w.shop_open(); - if (shop_open_seen && !shop_was_open) { - emit_signal("shop_opened", (int)w.shop_vid()); - } else if (!shop_open_seen && shop_was_open) { - emit_signal("shop_closed"); - } - } - for (uint8_t pos : w.drain_shop_slot_updates()) { - emit_signal("shop_updated", (int)pos); - } - for (const auto &err : w.drain_shop_errors()) { - emit_signal("shop_error", String::utf8(err.c_str())); - } - for (const auto &sc : w.drain_scripts()) { - emit_signal("script_dialog", (int)sc.skin, String::utf8(sc.text.c_str())); - } - for (const auto &cc : w.drain_confirms()) { - emit_signal("quest_confirm_ask", String::utf8(cc.msg.c_str()), (int)cc.timeout, - (int)cc.request_pid); - } - for (uint16_t qi : w.drain_quest_changes()) { - emit_signal("quest_info", (int)qi); - } - if (w.friends_dirty()) { - emit_signal("friends_changed"); - } - for (const auto &name : w.drain_friend_invites()) { - emit_signal("friend_invite_ask", String::utf8(name.c_str())); - } - if (w.take_lover_dirty()) { - emit_signal("lover_changed", get_lover()); - } - if (w.exchange_dirty()) { - emit_signal("exchange_changed"); - } - if (w.safebox_dirty()) { - bool safe_now = w.safebox_open(); - if (safe_now && !safebox_open_seen) { - emit_signal("safebox_opened", w.safebox_size()); - } else if (!safe_now && safebox_open_seen) { - emit_signal("safebox_closed"); - } - safebox_open_seen = safe_now; - emit_signal("safebox_changed"); - } - if (w.mall_dirty()) { - bool mall_now = w.mall_open(); - if (mall_now && !mall_open_seen) { - emit_signal("mall_opened", w.mall_size()); - } else if (!mall_now && mall_open_seen) { - emit_signal("mall_closed"); - } - mall_open_seen = mall_now; - emit_signal("mall_changed"); - } - for (const auto &ev : w.drain_cube_events()) { - using CE = mtnet::EntityStore::CubeEvent; - switch (ev) { - case CE::Opened: - emit_signal("cube_opened", (int)w.cube().npc_vnum); - break; - case CE::Closed: - emit_signal("cube_closed"); - break; - case CE::InfoChanged: - emit_signal("cube_changed"); - break; - case CE::Success: { - auto result = w.cube_last_success(); - emit_signal("cube_result", (int)result.vnum, result.count, true); - break; - } - case CE::Fail: - emit_signal("cube_result", 0, 0, false); - break; - } - } - if (w.guild_dirty()) { - emit_signal("guild_changed"); - } - if (w.guild_comments_dirty()) { - emit_signal("guild_comments_changed"); - } - for (const auto &invite : w.drain_guild_invites()) { - emit_signal("guild_invite_ask", (int64_t)invite.guild_id, - String::utf8(invite.guild_name.c_str())); - } - if (w.guild_skill_dirty()) { - emit_signal("guild_skill_changed"); - } - if (w.guild_war_dirty()) { - emit_signal("guild_war_changed"); - } - for (const auto &ev : w.drain_guild_war_events()) { - emit_signal("guild_war_event", (int)ev.state, (int64_t)ev.opp_guild_id, - String::utf8(w.guild_name(ev.opp_guild_id).c_str())); - } - for (const auto &score : w.drain_guild_war_scores()) { - emit_signal("guild_war_point", (int64_t)score.gain_guild_id, - (int64_t)score.opp_guild_id, score.point); - } - for (const auto &rc : w.drain_refine_cues()) { - Dictionary d; - d["type"] = (int)rc.type; - d["pos"] = (int)rc.pos; - d["src_vnum"] = (int)rc.src_vnum; - d["result_vnum"] = (int)rc.result_vnum; - d["cost"] = rc.cost; - d["prob"] = rc.prob; - Array mats; - for (int i = 0; i < rc.material_count && i < 5; ++i) { - if (rc.materials[i].vnum == 0) { - continue; - } - Dictionary mat; - mat["vnum"] = (int)rc.materials[i].vnum; - mat["count"] = rc.materials[i].count; - mats.push_back(mat); - } - d["materials"] = mats; - emit_signal("refine_ask", d); - } - for (const auto &dc : w.drain_ds_cues()) { - if (dc.sub_type == mtnet::DS_SUB_OPEN) { - emit_signal("ds_window_open"); - } else if (dc.sub_type == mtnet::DS_SUB_REFINE_SUCCEED) { - emit_signal("ds_refine_result", true, (int)dc.sub_type, (int)dc.cell); - } else if (dc.sub_type >= mtnet::DS_SUB_REFINE_FAIL && - dc.sub_type <= mtnet::DS_SUB_REFINE_FAIL_TOO_MUCH_MATERIAL) { - emit_signal("ds_refine_result", false, (int)dc.sub_type, (int)dc.cell); - } - } - mtnet::WarpCue warp; - if (w.take_next_warp(warp)) { - fmt::m2coord::Vec3 g = fmt::m2coord::position_to_godot(warp.x, warp.y, 0); - // ClientVS22's RecvWarpPacket() always calls Connect(lAddr, wPort), - // even for an address that points back to the current channel. Clear - // the old loading/world state before the socket is replaced. - classic_sess->reset_for_map_change(); - emit_signal("world_reset"); - emit_signal("warp", Vector3(g.x, g.y, g.z), false); - - uint32_t addr = static_cast(warp.addr); - char host[16]; - std::snprintf(host, sizeof(host), "%u.%u.%u.%u", addr & 0xFF, - (addr >> 8) & 0xFF, (addr >> 16) & 0xFF, (addr >> 24) & 0xFF); - classic_last_stage = -1; - shop_open_seen = false; - mall_open_seen = false; - safebox_open_seen = false; - dead_seen.clear(); - if (!classic_sess->connect_warp(std::string(host), warp.port)) { - // GC_WARP also sets DirectEnter before opening the replacement - // stream. Match PythonNetworkStream::OnConnectFailure: its failed - // replacement is owned by ClosePhase -> SetLoginPhase, not by the - // peer-close/disconnected path. - return; - } - set_stage(Stage::GameConnect); - return; - } - if (w.take_time_dirty()) { - emit_signal("time_changed", (int64_t)w.server_time()); - } - if (w.take_channel_dirty()) { - emit_signal("channel_changed", w.channel()); - } - if (w.take_npc_marks_dirty()) { - emit_signal("npc_marks_changed"); - } - if (w.take_land_dirty()) { - emit_signal("land_areas_changed"); - } - if (w.take_markers_dirty()) { - emit_signal("world_markers_changed"); - } - } -} - -void M2Client::pump_auth() { - auth->process(); - // 40250 clears AccountConnector::m_strPassword at the login-packet - // boundary. AuthClient exposes that same boundary so the host-side retry - // cache cannot retain a second copy of the credential. - if (auth->login_secret_cleared()) { - cfg_pw = String(); - } - - if ((int)auth->state() != last_auth_state) { - last_auth_state = (int)auth->state(); - if (auth->state() == mtnet::NetStream::State::Online && stage == Stage::AuthConnect) { - set_stage(Stage::AuthWait); - } - } - - if (!auth->done()) { - return; - } - - if (auth->success()) { - uint32_t key = auth->login_key(); - last_login_key = key; - emit_signal("auth_ok", (int64_t)key); - auth->disconnect(); - auth.reset(); - - game = std::make_unique(std::string(account_id.utf8().get_data()), key); - game->set_auto_enter_game(false); - loading_since_ms = 0; - enter_game_sent = false; - if (!game->connect(std::string(game_host.utf8().get_data()), (uint16_t)game_port)) { - emit_signal("login_failed", String(game->last_error().c_str())); - set_stage(Stage::Failed); - game.reset(); - return; - } - set_stage(Stage::GameConnect); - } else { - emit_signal("login_failed", - String(auth->fail_reason().empty() ? auth->last_error().c_str() - : auth->fail_reason().c_str())); - set_stage(Stage::Failed); - auth.reset(); - } -} - -void M2Client::pump_game() { - apply_item_proto_validator(); - game->process(); - if (game->empire_seen() && (int)game->empire() != last_game_empire) { - last_game_empire = game->empire(); - emit_signal("empire_changed", (int)game->empire()); - } - - if ((int)game->phase() != last_game_phase) { - last_game_phase = (int)game->phase(); - emit_signal("phase_changed", String(phase_name(last_game_phase))); - if (game->phase() != mtnet::PHASE_LOADING) { - loading_since_ms = 0; - } - if (game->phase() == mtnet::PHASE_LOGIN && stage == Stage::GameConnect) { - set_stage(Stage::GameLogin); - } - } - - if (game->char_list_ready() && !char_list_emitted) { - char_list_emitted = true; - emit_signal("char_list", build_char_list()); - } - - // create / delete results (server patches the slot list, doesn't re-send it) - for (const auto &ev : game->drain_char_events()) { - switch (ev.kind) { - case mtnet::GameClient::CharEvent::CreateOk: - emit_signal("char_list", build_char_list()); - emit_signal("char_created", ev.slot); - break; - case mtnet::GameClient::CharEvent::CreateFail: - emit_signal("char_create_failed", ev.fail_type); - break; - case mtnet::GameClient::CharEvent::DeleteOk: - emit_signal("char_list", build_char_list()); - emit_signal("char_deleted", ev.slot); - break; - case mtnet::GameClient::CharEvent::DeleteFail: - emit_signal("char_delete_failed"); - break; - } - } - - for (const auto &ev : game->drain_name_events()) { - emit_signal("char_list", build_char_list()); - emit_signal("char_name_changed", (int64_t)ev.pid, String::utf8(ev.name.c_str())); - } - for (int i = 0, n = game->drain_guild_make_requests(); i < n; ++i) { - emit_signal("guild_make_requested"); - } - - if (game->failed()) { - emit_signal("login_failed", String(game->fail_reason().c_str())); - set_stage(Stage::Failed); - game->disconnect(); - game.reset(); - return; - } - - if (game->phase() == mtnet::PHASE_LOADING && !enter_game_sent) { - uint64_t now = godot::Time::get_singleton()->get_ticks_msec(); - if (loading_since_ms == 0) { - loading_since_ms = now; - } else if (now - loading_since_ms > 1500) { - game->send_enter_game(); - enter_game_sent = true; - } - } - - if (game->in_game() && stage != Stage::InGame) { - set_stage(Stage::InGame); - emit_signal("entered_game"); - } - - // Advance the networked-world model and surface its deltas as signals. - if (game->in_game()) { - mtnet::EntityStore &w = game->world(); - w.set_now((uint32_t)Time::get_singleton()->get_ticks_msec()); - w.tick(); - for (const auto &c : w.drain_changes()) { - switch (c.kind) { - case mtnet::EntityStore::ChangeKind::Spawn: - if (const mtnet::Entity *e = w.get(c.vid)) { - emit_signal("entity_spawned", entity_dict(*e)); - } - break; - case mtnet::EntityStore::ChangeKind::Despawn: - dead_seen.erase(c.vid); - emit_signal("entity_despawned", (int)c.vid); - break; - case mtnet::EntityStore::ChangeKind::Move: - emit_signal("entity_moved", (int)c.vid); - break; - case mtnet::EntityStore::ChangeKind::MainSet: - emit_signal("entity_main_set", (int)c.vid); - break; - case mtnet::EntityStore::ChangeKind::Info: - if (const mtnet::Entity *e = w.get(c.vid)) { - emit_signal("entity_info", (int)c.vid, entity_dict(*e)); - } - break; - } - } - // §2.5: bare field updates (ownership / affect flags) with no Spawn/Move/ - // Info change — re-mirror the node so presentation stays in sync. - for (uint32_t vid : w.drain_dirty()) { - if (const mtnet::Entity *e = w.get(vid)) { - emit_signal("entity_info", (int)vid, entity_dict(*e)); - } - } - // §9.1: map background music. - if (w.take_bgm_dirty()) { - emit_signal("bgm_changed", String::utf8(w.bgm_name().c_str()), w.bgm_volume()); - } - for (const auto &m : w.drain_chat()) { - if (m.type == mtnet::CHAT_TYPE_WHISPER) { - emit_signal("whisper_received", (int)m.sub, String::utf8(m.from.c_str()), - String::utf8(m.text.c_str())); - } else { - emit_signal("chat", (int)m.type, (int)m.vid, String::utf8(m.text.c_str())); - } - } - // Keep the m2dev mirror in the same order as the classic mirror above. - for (const auto &pc : w.drain_point_changes()) { - emit_signal("point_changed", (int)pc.vid, (int)pc.type, (int)pc.value, - (int)pc.amount); - } - for (uint32_t vid : w.drain_vitals()) { - emit_signal("vitals_changed", (int)vid); - const mtnet::Entity *e = w.get(vid); - if (!e || !e->dead) { - dead_seen.erase(vid); - } - if (e && e->dead && !dead_seen.count(vid)) { - dead_seen.insert(vid); - emit_signal("entity_dead", (int)vid); - } - } - for (const auto &d : w.drain_damage()) { - emit_signal("damage", (int)d.vid, (int)d.amount, (int)d.flag); - } - for (const auto &mo : w.drain_motions()) { - emit_signal("motion", (int)mo.vid, (int)mo.victim_vid, (int)mo.motion); - } - for (const auto &dm : w.drain_dig_motions()) { - emit_signal("dig_motion", (int)dm.vid, (int)dm.target_vid, (int)dm.count); - } - for (const auto &fe : w.drain_fishing_events()) { - emit_signal("fishing_event", (int)fe.subheader, (int)fe.info, (int)fe.dir); - } - for (const auto &de : w.drain_dungeon_events()) { - emit_signal("dungeon_event", (int)de.subheader, (int)de.x, (int)de.y, - de.has_destination); - } - for (const auto &oe : w.drain_observer_events()) { - fmt::m2coord::Vec3 g = fmt::m2coord::position_to_godot(oe.x, oe.y, 0); - emit_signal("observer_event", (int)oe.kind, (int)oe.vid, - Vector2(g.x, g.z)); - } - for (const auto &ec : w.drain_effect_cues()) { - emit_signal("effect_cue", (int)ec.vid, String::utf8(ec.file.c_str()), - (int)ec.special); - } - for (const auto &fc : w.drain_fly_cues()) { - emit_signal("fly_cue", (int)fc.type, (int)fc.start_vid, (int)fc.end_vid); - } - for (const auto &tc : w.drain_fly_target_cues()) { - emit_signal("fly_targeting", (int)tc.shooter_vid, (int)tc.target_vid, - Vector2((float)tc.x, (float)tc.y), tc.append); - } - if (w.skills_dirty()) { - emit_signal("skills_changed"); - } - if (w.skill_group_dirty()) { - emit_signal("skill_group_changed", (int)w.skill_group()); - } - for (int sk : w.drain_cooldown_ends()) { - emit_signal("skill_cooldown_end", sk); - } - if (w.quickslots_dirty()) { - emit_signal("quickslots_changed"); - } - for (const auto &sc : w.drain_scripts()) { - emit_signal("script_dialog", (int)sc.skin, String::utf8(sc.text.c_str())); - } - for (const auto &cc : w.drain_confirms()) { - emit_signal("quest_confirm_ask", String::utf8(cc.msg.c_str()), (int)cc.timeout, - (int)cc.request_pid); - } - for (uint16_t qi : w.drain_quest_changes()) { - emit_signal("quest_info", (int)qi); - } - for (const auto &ac : w.drain_affects()) { - if (ac.added) { - for (const auto &a : w.affects()) { - if (a.type == ac.type) { - emit_signal("affect_added", affect_dict(a)); - break; - } - } - } else { - emit_signal("affect_removed", (int)ac.type); - } - } - if (w.take_points_dirty()) { - Dictionary points = get_points(); - points["exp_gain"] = w.take_exp_gain(); - emit_signal("points_changed", points); - } - if (w.take_target_dirty()) { - emit_signal("target_info", (int)w.target_vid(), (int)w.target_hp_pct()); - } - for (const auto &ih : w.drain_item_highlights()) { - emit_signal("item_highlighted", (int)ih.window, (int)ih.cell); - } - for (const auto &ic : w.drain_inv()) { - Dictionary item = item_dict(ic.item); - item["cell"] = (int)ic.cell; - item["window"] = (int)ic.window; - emit_signal("inventory_state_changed", (int)ic.window, (int)ic.cell, item); - emit_signal("inventory_changed", (int)ic.window, (int)ic.cell); - } - for (const auto &gc : w.drain_ground()) { - if (gc.added) { - const mtnet::GroundItem *g = w.ground(gc.vid); - if (g) { - Dictionary d; - d["vid"] = (int)g->vid; - d["vnum"] = (int)g->vnum; - d["owner"] = String::utf8(g->owner.c_str()); - fmt::m2coord::Vec3 gp = fmt::m2coord::position_to_godot(g->x, g->y, g->z); - d["pos"] = Vector3(gp.x, gp.y, gp.z); - emit_signal("ground_item_added", d); - } - } else { - emit_signal("ground_item_removed", (int)gc.vid); - } - } - for (uint32_t vid : w.drain_view_equipment_changes()) { - emit_signal("view_equipment", (int)vid); - } - for (const auto &p : w.drain_pvp_changes()) { - emit_signal("pvp_changed", (int)p.src_vid, (int)p.dst_vid, (int)p.mode); - } - if (w.take_duel_started()) { - emit_signal("duel_changed", get_duel()); - emit_signal("duel_started"); - } - for (const auto &ie : w.drain_item_events()) { - if (ie.kind == mtnet::ItemEvent::Get) { - emit_signal("item_picked_up", (int)ie.vnum, (int)ie.count, - String::utf8(ie.from.c_str())); - } else { - emit_signal("item_used", (int)ie.vnum); - } - } - // P8: social / shop / storage - for (uint32_t leader_pid : w.drain_party_invites()) { - emit_signal("party_invite_ask", (int)leader_pid); - } - for (const auto &ev : w.drain_server_commands()) { - switch (ev.kind) { - case mtnet::ServerCommandEvent::ServerQuitRequested: - emit_signal("server_quit_requested"); - break; - case mtnet::ServerCommandEvent::PartyRequestDenied: - emit_signal("party_request_denied"); - break; - case mtnet::ServerCommandEvent::LoverLogin: - case mtnet::ServerCommandEvent::LoverLogout: - case mtnet::ServerCommandEvent::LoverNear: - case mtnet::ServerCommandEvent::LoverFar: - case mtnet::ServerCommandEvent::LoverDivorce: - // LoverInfo 由 mutator 同步更新;lover_changed 信号在 - // take_lover_dirty() 处统一发出,这里无需单独 emit。 - break; - case mtnet::ServerCommandEvent::SafeboxPasswordRequired: - emit_signal("safebox_password_required"); - break; - case mtnet::ServerCommandEvent::SafeboxWrongPassword: - emit_signal("safebox_wrong_password"); - break; - case mtnet::ServerCommandEvent::MallPasswordRequired: - emit_signal("mall_password_required"); - break; - case mtnet::ServerCommandEvent::RefineSucceeded: - emit_signal("refine_result", true); - break; - case mtnet::ServerCommandEvent::RefineFailed: - emit_signal("refine_result", false); - break; - case mtnet::ServerCommandEvent::PrivateShopOpenRequested: - emit_signal("private_shop_open_requested"); - break; - case mtnet::ServerCommandEvent::MyShopPrice: - emit_signal("my_shop_price_list", (int)ev.value, (int)ev.value2); - break; - case mtnet::ServerCommandEvent::BlockModeChanged: - emit_signal("block_mode_changed", (int)ev.value); - break; - case mtnet::ServerCommandEvent::ObserverModeChanged: - emit_signal("observer_mode_changed", ev.value != 0); - break; - case mtnet::ServerCommandEvent::ObserverCountChanged: - emit_signal("observer_count_changed", (int)ev.value); - break; - case mtnet::ServerCommandEvent::StoneDetected: - emit_signal("stone_detected", (int)ev.value, (int)ev.value2, ev.value3); - break; - case mtnet::ServerCommandEvent::StaminaStarted: - emit_signal("stamina_changed", (int)ev.value2, (int)ev.value, true); - break; - case mtnet::ServerCommandEvent::StaminaStopped: - emit_signal("stamina_changed", (int)ev.value2, 0, false); - break; - case mtnet::ServerCommandEvent::MobileFlagChanged: - emit_signal("mobile_flag_changed", ev.value != 0); - break; - case mtnet::ServerCommandEvent::MobileAuthRequired: - emit_signal("mobile_auth_required"); - break; - case mtnet::ServerCommandEvent::ComboChanged: - emit_signal("combo_changed", ev.value != 0); - break; - case mtnet::ServerCommandEvent::GiftAvailable: - emit_signal("gift_available"); - break; - case mtnet::ServerCommandEvent::WeatherChanged: - emit_signal("weather_changed", ev.value != 0); - break; - } - } - if (w.party_dirty()) { - emit_signal("party_changed"); - } - if (w.take_lover_dirty()) { - emit_signal("lover_changed", get_lover()); - } - if (w.friends_dirty()) { - emit_signal("friends_changed"); - } - for (const auto &name : w.drain_friend_invites()) { - emit_signal("friend_invite_ask", String::utf8(name.c_str())); - } - bool shop_was_open = shop_open_seen; - if (w.shop_dirty()) { - shop_open_seen = w.shop_open(); - if (shop_open_seen && !shop_was_open) { - emit_signal("shop_opened", (int)w.shop_vid()); - } else if (!shop_open_seen && shop_was_open) { - emit_signal("shop_closed"); - } - } - for (uint8_t pos : w.drain_shop_slot_updates()) { - emit_signal("shop_updated", (int)pos); - } - for (const auto &err : w.drain_shop_errors()) { - emit_signal("shop_error", String::utf8(err.c_str())); - } - if (w.exchange_dirty()) { - emit_signal("exchange_changed"); - } - if (w.safebox_dirty()) { - bool safe_now = w.safebox_open(); - if (safe_now && !safebox_open_seen) { - emit_signal("safebox_opened", w.safebox_size()); - } else if (!safe_now && safebox_open_seen) { - emit_signal("safebox_closed"); - } - safebox_open_seen = safe_now; - emit_signal("safebox_changed"); - } - if (w.mall_dirty()) { - bool mall_now = w.mall_open(); - if (mall_now && !mall_open_seen) { - emit_signal("mall_opened", w.mall_size()); - } else if (!mall_now && mall_open_seen) { - emit_signal("mall_closed"); - } - mall_open_seen = mall_now; - emit_signal("mall_changed"); - } - for (const auto &ev : w.drain_cube_events()) { - using CE = mtnet::EntityStore::CubeEvent; - switch (ev) { - case CE::Opened: - emit_signal("cube_opened", (int)w.cube().npc_vnum); - break; - case CE::Closed: - emit_signal("cube_closed"); - break; - case CE::InfoChanged: - emit_signal("cube_changed"); - break; - case CE::Success: { - auto s = w.cube_last_success(); - emit_signal("cube_result", (int)s.vnum, s.count, true); - break; - } - case CE::Fail: - emit_signal("cube_result", 0, 0, false); - break; - } - } - if (w.guild_dirty()) { - emit_signal("guild_changed"); - } - if (w.guild_comments_dirty()) { - emit_signal("guild_comments_changed"); - } - for (const auto &invite : w.drain_guild_invites()) { - emit_signal("guild_invite_ask", (int64_t)invite.guild_id, - String::utf8(invite.guild_name.c_str())); - } - if (w.guild_skill_dirty()) { - emit_signal("guild_skill_changed"); - } - if (w.guild_war_dirty()) { - emit_signal("guild_war_changed"); - } - for (const auto &ev : w.drain_guild_war_events()) { - emit_signal("guild_war_event", (int)ev.state, (int64_t)ev.opp_guild_id, - String::utf8(w.guild_name(ev.opp_guild_id).c_str())); - } - for (const auto &sc : w.drain_guild_war_scores()) { - emit_signal("guild_war_point", (int64_t)sc.gain_guild_id, (int64_t)sc.opp_guild_id, - sc.point); - } - for (const auto &mu : game->drain_mark_updates()) { - emit_signal("guild_mark_updated", (int)mu.guild_id, (int)mu.img_idx); - } - for (const auto &rc : w.drain_refine_cues()) { - Dictionary d; - d["type"] = (int)rc.type; - d["pos"] = (int)rc.pos; - d["src_vnum"] = (int)rc.src_vnum; - d["result_vnum"] = (int)rc.result_vnum; - d["cost"] = rc.cost; - d["prob"] = rc.prob; - Array mats; - for (int i = 0; i < rc.material_count && i < 5; ++i) { - if (rc.materials[i].vnum == 0) { - continue; - } - Dictionary m; - m["vnum"] = (int)rc.materials[i].vnum; - m["count"] = rc.materials[i].count; - mats.push_back(m); - } - d["materials"] = mats; - emit_signal("refine_ask", d); - } - for (const auto &dc : w.drain_ds_cues()) { - if (dc.sub_type == mtnet::DS_SUB_OPEN) { - emit_signal("ds_window_open"); - } else if (dc.sub_type == mtnet::DS_SUB_REFINE_SUCCEED) { - emit_signal("ds_refine_result", true, (int)dc.sub_type, (int)dc.cell); - } else if (dc.sub_type >= mtnet::DS_SUB_REFINE_FAIL && - dc.sub_type <= mtnet::DS_SUB_REFINE_FAIL_TOO_MUCH_MATERIAL) { - emit_signal("ds_refine_result", false, (int)dc.sub_type, (int)dc.cell); - } - } - // P9: world systems - mtnet::WarpCue warp; - if (w.take_next_warp(warp)) { - fmt::m2coord::Vec3 g = fmt::m2coord::position_to_godot(warp.x, warp.y, 0); - emit_signal("warp", Vector3(g.x, g.y, g.z), warp.same_server()); - if (!warp.same_server()) { - // int32 lAddr is a network-order IPv4; format dotted-quad. - uint32_t a = (uint32_t)warp.addr; - char host[16]; - std::snprintf(host, sizeof(host), "%u.%u.%u.%u", a & 0xFF, (a >> 8) & 0xFF, - (a >> 16) & 0xFF, (a >> 24) & 0xFF); - warp_to_game_server(String(host), warp.port); - return; // game client is being torn down; stop touching w - } - } - if (w.take_time_dirty()) { - emit_signal("time_changed", (int64_t)w.server_time()); - } - if (w.take_channel_dirty()) { - emit_signal("channel_changed", w.channel()); - } - if (w.take_npc_marks_dirty()) { - emit_signal("npc_marks_changed"); - } - if (w.take_land_dirty()) { - emit_signal("land_areas_changed"); - } - if (w.take_markers_dirty()) { - emit_signal("world_markers_changed"); - } - for (uint32_t vid : w.drain_mount_changes()) { - emit_signal("mount_changed", (int)vid); - } - } - - if (game->state() == mtnet::NetStream::State::Offline && stage != Stage::Failed) { - emit_signal("disconnected", String(game->last_error().c_str())); - set_stage(Stage::Failed); - game.reset(); - } -} - -} // namespace mtgodot diff --git a/extension/src/net/m2_client.h b/extension/src/net/m2_client.h deleted file mode 100644 index ccfda760..00000000 --- a/extension/src/net/m2_client.h +++ /dev/null @@ -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 -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include - -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 ". - 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 , 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 " - - // --- 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 &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 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 auth; - std::unique_ptr 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 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 mark; - std::unique_ptr classic_mark; - std::unique_ptr 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 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 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 diff --git a/extension/src/net/text_codec.h b/extension/src/net/text_codec.h index e19f9ae1..463599b3 100644 --- a/extension/src/net/text_codec.h +++ b/extension/src/net/text_codec.h @@ -7,29 +7,18 @@ // with the Chinese build using GB2312-compatible bytes) and fills fixed C // buffers by byte capacity, never by UTF-8 character count. // -// This project carries text as UTF-8 end to end (GDScript String -> -// std::string). The public helpers below are the one boundary where fixed +// This project carries text as UTF-8 in 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 // titles, system text and other dynamic text must use their own packet capacity // and must not be validated as CHARACTER_NAME_MAX_LEN fields. // -// mtnet (ClassicParser / ClassicSession) links **without** godot-cpp, so the -// std::string_view core below is the one those call sites use. The -// godot::String overloads are compiled only where is on the -// include path (the M2Client / GDScript boundary). +// ClassicParser and ClassicSession use the std::string_view API below. #include #include #include #include -#if defined(__has_include) -# if __has_include() -# include -# include -# define MT_TEXT_CODEC_HAS_GODOT 1 -# endif -#endif 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; } -#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(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(s.size())); -} -#endif - } // namespace mtnet diff --git a/extension/src/pack40250_node.cpp b/extension/src/pack40250_node.cpp deleted file mode 100644 index 831b3d1d..00000000 --- a/extension/src/pack40250_node.cpp +++ /dev/null @@ -1,35 +0,0 @@ -#include "pack40250_node.h" - -#include - -#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 diff --git a/extension/src/pack40250_node.h b/extension/src/pack40250_node.h deleted file mode 100644 index 36b85aea..00000000 --- a/extension/src/pack40250_node.h +++ /dev/null @@ -1,28 +0,0 @@ -#pragma once -// Metin2Pack — GDScript access to the 40250 packs (platform/PackBackend.h). -// -// Metin2Pack.initialize(AssetRoot.client_path()) # registers /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 -#include -#include - -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 diff --git a/extension/src/platform/ScriptLib/PythonBoot.cpp b/extension/src/platform/ScriptLib/PythonBoot.cpp index 0b80e3ed..3d7d99c1 100644 --- a/extension/src/platform/ScriptLib/PythonBoot.cpp +++ b/extension/src/platform/ScriptLib/PythonBoot.cpp @@ -8,6 +8,7 @@ #include "EterBase/Timer.h" #include "EterLib/Util.h" #include "EterLib/GrpBase.h" +#include "EterLib/GrpImage.h" #include "EterLib/Camera.h" #include "../EterLib/HostCursor.h" #include "UserInterface/PythonNetworkStream.h" @@ -408,6 +409,44 @@ void SetAttackKey(bool pressed) CPythonPlayer::Instance().SetAttackKeyState(pressed); } +void UseLocalQuickSlot(int localSlotIndex) +{ + if (!g_game_singletons || localSlotIndex < 0 || localSlotIndex >= 8) return; + CPythonPlayer::Instance().RequestUseLocalQuickSlot(static_cast(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(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) { CCameraManager& rkCmrMgr = CCameraManager::Instance(); diff --git a/extension/src/platform/ScriptLib/PythonBoot.h b/extension/src/platform/ScriptLib/PythonBoot.h index 3cba1891..008f295c 100644 --- a/extension/src/platform/ScriptLib/PythonBoot.h +++ b/extension/src/platform/ScriptLib/PythonBoot.h @@ -66,6 +66,9 @@ void UIKey(int key, bool pressed); // Mobile touch & joystick controls void SetMoveDirection(float angleDeg, bool moving); void SetAttackKey(bool pressed); +void UseLocalQuickSlot(int localSlotIndex); +bool TryAssignAttachedObjectToLocalQuickSlot(int localSlotIndex); +std::string LocalQuickSlotSkillIcon(int localSlotIndex); void CameraBeginDrag(int x, int y); void CameraDrag(int x, int y); void CameraEndDrag(); diff --git a/extension/src/port/CMakeLists.txt b/extension/src/port/CMakeLists.txt index 0f2c5a06..2c8005bf 100644 --- a/extension/src/port/CMakeLists.txt +++ b/extension/src/port/CMakeLists.txt @@ -1,6 +1,5 @@ # port_logic — mirror of the 40250 client logic (extension/src/port//.{h,cpp}). -# No godot-cpp dependency: libmtgodot links it, and it also builds on its own (top-level -# -DMTGODOT_BUILD_EXTENSION=OFF -DMTGODOT_BUILD_PORT=ON) for targets without an extension build. +# This library and port_platform are built directly for the SDL/Vulkan client and port tests. # See docs/PORT-PLAN.md batch 2A. 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: (EterBase/lzo.h), # (EterBase/cipher.h), (ScriptLib). 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 -# mtnet; the port-only build (script/port_gate.sh, native_render) never reads that file. +# common/Win32Crt.cpp converts ANSI code pages with mt_codepage. if(NOT TARGET mt_codepage) add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/../codepage ${CMAKE_CURRENT_BINARY_DIR}/../codepage) 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). add_executable(eterpack_pack ${CMAKE_CURRENT_SOURCE_DIR}/../../tests/eterpack_pack.cpp) target_link_libraries(eterpack_pack PRIVATE port_platform) - # 2V2-e: the same fake server as a process, for the Godot-driven render run - # (script/python_game_render_test.sh). + # The same fake server as a process, for native rendering tests. 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}/../net/classic/classic_cipher.cpp) diff --git a/extension/src/port/EterPack/EterPack.cpp b/extension/src/port/EterPack/EterPack.cpp index 65d66dd0..75b6cb18 100644 --- a/extension/src/port/EterPack/EterPack.cpp +++ b/extension/src/port/EterPack/EterPack.cpp @@ -33,6 +33,32 @@ #include #include +#if !defined(_WIN32) +#include // PORT: resolve separately cased .eix/.epk names on case-sensitive mobile filesystems +#include // 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) { @@ -257,6 +283,18 @@ bool CEterPack::Create(CEterFileDict& rkFileDict, const char * dbname, const cha m_stDataFileName = dbname; 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; diff --git a/extension/src/port/EterPythonLib/PythonWindow.cpp b/extension/src/port/EterPythonLib/PythonWindow.cpp index 9c0a2e63..49dfcde7 100644 --- a/extension/src/port/EterPythonLib/PythonWindow.cpp +++ b/extension/src/port/EterPythonLib/PythonWindow.cpp @@ -1,4 +1,6 @@ #include "StdAfx.h" +#include +#include #include "../EterBase/CRC32.h" #include "PythonWindow.h" #include "PythonSlotWindow.h" @@ -1392,7 +1394,7 @@ namespace UI return FALSE; } - CExpandedImageBox::CExpandedImageBox(PyObject * ppyObject) : CImageBox(ppyObject) + CExpandedImageBox::CExpandedImageBox(PyObject * ppyObject) : CImageBox(ppyObject), m_bAspectFill(false) { } CExpandedImageBox::~CExpandedImageBox() @@ -1400,6 +1402,73 @@ namespace UI 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() { OnDestroyInstance(); @@ -1420,9 +1489,56 @@ namespace UI 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); + ((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); 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) { if (!m_pImageInstance) @@ -1458,6 +1574,24 @@ namespace UI 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); + ((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()) m_pImageInstance->Render(); } diff --git a/extension/src/port/EterPythonLib/PythonWindow.h b/extension/src/port/EterPythonLib/PythonWindow.h index 6cbfcb08..a90b1f3b 100644 --- a/extension/src/port/EterPythonLib/PythonWindow.h +++ b/extension/src/port/EterPythonLib/PythonWindow.h @@ -386,6 +386,8 @@ namespace UI void SetRotation(float fRotation); void SetRenderingRect(float fLeft, float fTop, float fRight, float fBottom); void SetRenderingMode(int iMode); + void SetAspectFill(bool bEnable); + bool IsAspectFill() const; protected: void OnCreateInstance(); @@ -393,8 +395,14 @@ namespace UI virtual void OnUpdate(); virtual void OnRender(); + virtual void OnChangePosition(); BOOL OnIsType(DWORD dwType); + + bool IsAutoAspectFillBackground() const; + + protected: + bool m_bAspectFill; }; class CAniImageBox : public CWindow { diff --git a/extension/src/port/EterPythonLib/PythonWindowManagerModule.cpp b/extension/src/port/EterPythonLib/PythonWindowManagerModule.cpp index ba38b9f7..ae263241 100644 --- a/extension/src/port/EterPythonLib/PythonWindowManagerModule.cpp +++ b/extension/src/port/EterPythonLib/PythonWindowManagerModule.cpp @@ -2012,6 +2012,27 @@ PyObject * wndImageSetScale(PyObject * poSelf, PyObject * poArgs) 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) { UI::CWindow * pWindow; @@ -2454,6 +2475,7 @@ void initwndMgr() { "GetHeight", wndImageGetHeight, METH_VARARGS }, // ExpandedImageBox { "SetScale", wndImageSetScale, METH_VARARGS }, + { "SetAspectFill", wndImageSetAspectFill, METH_VARARGS }, { "SetOrigin", wndImageSetOrigin, METH_VARARGS }, { "SetRotation", wndImageSetRotation, METH_VARARGS }, { "SetRenderingRect", wndImageSetRenderingRect, METH_VARARGS }, diff --git a/extension/src/proto/proto_node.cpp b/extension/src/proto/proto_node.cpp deleted file mode 100644 index 69f6f239..00000000 --- a/extension/src/proto/proto_node.cpp +++ /dev/null @@ -1,164 +0,0 @@ -#include "proto_node.h" - -#include "../asset_io.h" - -#include - -#include - -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(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 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 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 diff --git a/extension/src/proto/proto_node.h b/extension/src/proto/proto_node.h deleted file mode 100644 index 8f25dd9d..00000000 --- a/extension/src/proto/proto_node.h +++ /dev/null @@ -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 -#include -#include -#include - -#include -#include - -#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 m_item_ix; // vnum -> record index - std::unordered_map m_mob_ix; - godot::String last_error; -}; - -} // namespace mtgodot diff --git a/extension/src/python_host_node.cpp b/extension/src/python_host_node.cpp deleted file mode 100644 index ee556895..00000000 --- a/extension/src/python_host_node.cpp +++ /dev/null @@ -1,669 +0,0 @@ -#include "python_host_node.h" - -#include -#include - -#include "python_stdlib.h" - -#ifdef MTGODOT_HAVE_PYTHON -// Standard types only — PythonBoot.h keeps 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 -#include -#include -#include -#include -#endif - -#include -#include -#include -#include -#include -#include -#include -#include -#include - - -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(command.argb); - if (command.kind == UIRenderCommand::GradientBar) - item[s_end_argb] = static_cast(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(commands.size())); - for (size_t idx = 0; idx < commands.size(); ++idx) - out[static_cast(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(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(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(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 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(); - if (known_revision >= 0 && texture.revision == static_cast(known_revision)) - return Ref(); - PackedByteArray rgba; - rgba.resize(static_cast(texture.argb.size()) * 4); - uint8_t *out = rgba.ptrw(); - for (std::uint32_t argb : texture.argb) { - *out++ = static_cast(argb >> 16); - *out++ = static_cast(argb >> 8); - *out++ = static_cast(argb); - *out++ = static_cast(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 &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(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 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> 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(mem_tex.width), - static_cast(mem_tex.height), - mem_tex.argb.data()); - if (!mtra.empty()) - textures.emplace(name, std::move(mtra)); - } - return; - } - std::vector 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(draws.size()), - " ui_commands=", static_cast(ui_commands.size()), - " textures=", static_cast(textures.size())); - } catch (const std::exception &error) { - UtilityFunctions::printerr("native draw capture failed: ", error.what()); - } - } - } - - Array out; - out.resize(static_cast(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(draw.geometry_key); - item["geometry_revision"] = static_cast(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(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(draw.positions.size() / 3)); - if constexpr (sizeof(Vector3) == sizeof(float) * 3 && std::is_trivially_copyable_v) { - 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(draw.rhw.size())); - if (!draw.normals.empty()) { - PackedVector3Array normals; - normals.resize(static_cast(draw.normals.size() / 3)); - if constexpr (sizeof(Vector3) == sizeof(float) * 3 && std::is_trivially_copyable_v) { - 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 &values) { - PackedVector2Array out; - out.resize(static_cast(values.size() / 2)); - if constexpr (sizeof(Vector2) == sizeof(float) * 2 && std::is_trivially_copyable_v) { - 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 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(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(draw.indices.size())); - for (int64_t i = 0; i < indices.size(); ++i) - indices[i] = static_cast(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(draw.alpha_blend); - item["src_blend"] = static_cast(draw.src_blend); - item["dest_blend"] = static_cast(draw.dest_blend); - item["alpha_test"] = static_cast(draw.alpha_test); - item["alpha_ref"] = static_cast(draw.alpha_ref); - item["alpha_func"] = static_cast(draw.alpha_func); - item["cull_mode"] = static_cast(draw.cull_mode); - item["z_enable"] = static_cast(draw.z_enable); - item["z_write"] = static_cast(draw.z_write); - item["z_func"] = static_cast(draw.z_func); - item["lighting"] = static_cast(draw.lighting); - item["texture_factor"] = static_cast(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(draw.fog_enable); - item["color_op"] = static_cast(draw.color_op[0]); - item["alpha_op"] = static_cast(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 Metin2PythonHost::memory_texture(const String &, int64_t) { return Ref(); } -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 diff --git a/extension/src/python_host_node.h b/extension/src/python_host_node.h deleted file mode 100644 index 43eeda6e..00000000 --- a/extension/src/python_host_node.h +++ /dev/null @@ -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 -#include -#include -#include -#include - -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:@" 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 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 diff --git a/extension/src/python_stdlib.cpp b/extension/src/python_stdlib.cpp deleted file mode 100644 index a20f413b..00000000 --- a/extension/src/python_stdlib.cpp +++ /dev/null @@ -1,133 +0,0 @@ -#include "python_stdlib.h" - -#include -#include -#include -#include -#include - -#include -#include - -#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 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 diff --git a/extension/src/python_stdlib.h b/extension/src/python_stdlib.h deleted file mode 100644 index 53aa796d..00000000 --- a/extension/src/python_stdlib.h +++ /dev/null @@ -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 -#include - -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 diff --git a/extension/src/register_types.cpp b/extension/src/register_types.cpp deleted file mode 100644 index 4d87e96b..00000000 --- a/extension/src/register_types.cpp +++ /dev/null @@ -1,82 +0,0 @@ -#include "register_types.h" - -#include -#include -#include - -#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 // fmt::set_file_reader -#include - -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(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(); -} -} diff --git a/extension/src/register_types.h b/extension/src/register_types.h deleted file mode 100644 index 6c942e64..00000000 --- a/extension/src/register_types.h +++ /dev/null @@ -1,6 +0,0 @@ -#pragma once - -#include - -void initialize_mtgodot_module(godot::ModuleInitializationLevel p_level); -void uninitialize_mtgodot_module(godot::ModuleInitializationLevel p_level); diff --git a/extension/src/static_object.cpp b/extension/src/static_object.cpp deleted file mode 100644 index e7e759e6..00000000 --- a/extension/src/static_object.cpp +++ /dev/null @@ -1,163 +0,0 @@ -#include "static_object.h" - -#include "asset_io.h" -#include "dxt.h" -#include "gr2_bridge.h" -#include "texture_util.h" - -#include -#include -#include -#include -#include - -#include -#include - -#include - -using namespace godot; - -namespace mtgodot { - -namespace { - -std::unordered_map> 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 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 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 material_for(const std::string &tex_name, bool alpha_blend, bool two_sided, - const fmt::AssetResolver &res) { - Ref 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 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 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 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 mats = gr2::dump_materials(*loaded); - std::map> 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 parts = mtgodot::build_parts(fi); - AABB bounds; - Ref 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 diff --git a/extension/src/static_object.h b/extension/src/static_object.h deleted file mode 100644 index daf440ef..00000000 --- a/extension/src/static_object.h +++ /dev/null @@ -1,35 +0,0 @@ -#pragma once - -#include -#include - -#include -#include - -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> by_path; - int loaded = 0, failed = 0; -}; - -// real_gr2_path = AssetResolver 解析后的真实路径。返回共享 ArrayMesh(含材质)。 -// 失败返回 invalid Ref。 -godot::Ref get_static_mesh(const std::string &real_gr2_path, - const fmt::AssetResolver &res, StaticMeshCache &cache); - -// 退出时清缓存的 Ref(别拖到 __cxa_finalize)。 -void cleanup_static_object_cache(); - -} // namespace mtgodot diff --git a/extension/src/terrain_splat.cpp b/extension/src/terrain_splat.cpp deleted file mode 100644 index 5733220e..00000000 --- a/extension/src/terrain_splat.cpp +++ /dev/null @@ -1,264 +0,0 @@ -#include "terrain_splat.h" - -#include "asset_io.h" -#include "dxt.h" - -#include -#include -#include -#include -#include -#include -#include - -#include - -#include -#include -#include -#include -#include -#include - -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 g_terrain_shader; -Ref 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> g_layer_cache; -std::unordered_map> g_layer_dimensions; -Ref layer_image(const std::string &real_path, int size, - std::unordered_map &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 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 &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 weight_tex(const std::vector &four) { - std::vector 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 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,别拖到 __cxa_finalize(那时引擎已析构) - g_layer_dimensions.clear(); -} - -Ref 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 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(MAX_LAYERS, (int)splat.layers.size()); - if (n == 0) - return Ref(); - - // 颜色数组 - TypedArray imgs; - std::vector uvparm(MAX_LAYERS, Color(40, 40, 0, 0)); - Ref 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 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 arr; - arr.instantiate(); - arr->create_from_images(imgs); - const auto array_done = now(); - - // 权重贴图:ceil(n/4) 张 RGBA8(每通道一层 alpha) - Ref wtex[4]; - for (int g = 0; g < 4; ++g) { - std::vector 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 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 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(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 diff --git a/extension/src/terrain_splat.h b/extension/src/terrain_splat.h deleted file mode 100644 index 80eae045..00000000 --- a/extension/src/terrain_splat.h +++ /dev/null @@ -1,32 +0,0 @@ -#pragma once - -#include -#include -#include -#include - -#include -#include - -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 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 diff --git a/extension/src/texture_util.cpp b/extension/src/texture_util.cpp deleted file mode 100644 index 450a5d74..00000000 --- a/extension/src/texture_util.cpp +++ /dev/null @@ -1,76 +0,0 @@ -#include "texture_util.h" - -#include -#include - -#include -#include -#include -#include - -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 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(); - } - - PackedByteArray bytes; - bytes.resize(int64_t(w) * h * 4); - std::memcpy(bytes.ptrw(), rgba, size_t(w) * size_t(h) * 4); - - Ref img = Image::create_from_data(w, h, false, Image::FORMAT_RGBA8, bytes); - if (img.is_null()) { - return Ref(); - } - 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 diff --git a/extension/src/texture_util.h b/extension/src/texture_util.h deleted file mode 100644 index aa3abb8a..00000000 --- a/extension/src/texture_util.h +++ /dev/null @@ -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 -#include - -#include -#include -#include - -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 make_color_texture( - int w, int h, const uint8_t *rgba, size_t len, bool mipmaps, - TexUse use = TexUse::COLOR); - -} // namespace mtgodot diff --git a/extension/src/tree_placeholder.cpp b/extension/src/tree_placeholder.cpp deleted file mode 100644 index 94951993..00000000 --- a/extension/src/tree_placeholder.cpp +++ /dev/null @@ -1,542 +0,0 @@ -#include "tree_placeholder.h" - -#include "asset_io.h" -#include "dxt.h" -#include "texture_util.h" - -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include - -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 g_leaf_shader; -Ref g_fallback_broadleaf; -Ref g_fallback_conifer; -std::unordered_map> g_tree_texture_cache; -std::unordered_map> g_tree_mesh_cache; - -Ref leaf_shader() { - if (g_leaf_shader.is_null()) { - g_leaf_shader.instantiate(); - g_leaf_shader->set_code(SRC_LEAF); - } - return g_leaf_shader; -} - -Ref fallback_leaf_texture(bool conifer) { - Ref &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 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 load_dds_texture(const std::string &path) { - if (path.empty()) - return Ref(); - auto found = g_tree_texture_cache.find(path); - if (found != g_tree_texture_cache.end()) - return found->second; - Ref 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 bark; - Ref 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 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 &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 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 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 mesh; - mesh.instantiate(); - mesh->add_surface_from_arrays(Mesh::PRIMITIVE_TRIANGLES, wood.arrays()); - mesh->add_surface_from_arrays(Mesh::PRIMITIVE_TRIANGLES, leaves.arrays()); - - Ref 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 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 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 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 document; - document.instantiate(); - Ref state; - state.instantiate(); - if (document->append_from_file(path, state) != OK || state->get_meshes().size() != 1) return {}; - Ref gltf_mesh = state->get_meshes()[0]; - if (gltf_mesh.is_null() || gltf_mesh->get_mesh().is_null()) return {}; - Ref 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 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 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 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 diff --git a/extension/src/tree_placeholder.h b/extension/src/tree_placeholder.h deleted file mode 100644 index ca3bb1d5..00000000 --- a/extension/src/tree_placeholder.h +++ /dev/null @@ -1,35 +0,0 @@ -#pragma once - -#include -#include -#include - -#include - -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 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 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 diff --git a/extension/src/water_builder.cpp b/extension/src/water_builder.cpp deleted file mode 100644 index 70672a3f..00000000 --- a/extension/src/water_builder.cpp +++ /dev/null @@ -1,218 +0,0 @@ -#include "water_builder.h" -#include "water_motion.h" - -#include "asset_io.h" -#include "dxt.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include // terrain_height_at - -#include - -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 g_water_shader; -Ref g_water_mat; // 30 帧数组只建一次 -WaterMotion g_water_motion; - -Ref 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 m; - m.instantiate(); - m->set_shader(g_water_shader); - - // special/water/01..30.dds - TypedArray 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 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 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 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 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 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 diff --git a/extension/src/water_builder.h b/extension/src/water_builder.h deleted file mode 100644 index 3bf8f1d8..00000000 --- a/extension/src/water_builder.h +++ /dev/null @@ -1,33 +0,0 @@ -#pragma once -#include "water_depth.h" - -#include -#include - -#include - -#include - -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 mesh; // Godot 空间,已含区块原点 - float godot_y = 0; -}; - -// hm/height_scale 用来算每个水面顶点下方的地形高度 -> 水深 -> alpha。 -std::vector 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 diff --git a/extension/src/water_depth.h b/extension/src/water_depth.h deleted file mode 100644 index 152e2770..00000000 --- a/extension/src/water_depth.h +++ /dev/null @@ -1,12 +0,0 @@ -#pragma once -#include - -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(alpha > 0.8 ? 0.8 : alpha); -} -} diff --git a/extension/src/water_motion.h b/extension/src/water_motion.h deleted file mode 100644 index 7d4b84da..00000000 --- a/extension/src/water_motion.h +++ /dev/null @@ -1,24 +0,0 @@ -#pragma once -#include -#include - -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(next_duration, 1000, 3000); - } - current = begin + (end - begin) * double(now - start_ms) / double(duration_ms); - return current; - } -}; -} diff --git a/extension/tests/dxt_decode_test.cpp b/extension/tests/dxt_decode_test.cpp index 3ddf3d30..d4395005 100644 --- a/extension/tests/dxt_decode_test.cpp +++ b/extension/tests/dxt_decode_test.cpp @@ -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); return out; } -mtgodot::Image decode(const Bytes& b) { return mtgodot::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) { +mtimage::Image decode(const Bytes& b) { return mtimage::load_dds(b.data(), b.size()); } +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); } int main() { diff --git a/extension/tests/water_depth_test.cpp b/extension/tests/water_depth_test.cpp deleted file mode 100644 index 1dc524d1..00000000 --- a/extension/tests/water_depth_test.cpp +++ /dev/null @@ -1,17 +0,0 @@ -#include "water_depth.h" -#include -#include -#include -#include -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::quiet_NaN(), 1) == 0); - std::puts("PASS: water depth reference (raw depth, shoreline, cap, height scale)"); -} diff --git a/extension/tests/water_motion_test.cpp b/extension/tests/water_motion_test.cpp deleted file mode 100644 index f88b5e37..00000000 --- a/extension/tests/water_motion_test.cpp +++ /dev/null @@ -1,21 +0,0 @@ -#include "water_motion.h" -#include -#include -#include -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"); -} diff --git a/extension/third_party/CMakeLists.txt b/extension/third_party/CMakeLists.txt index 6e641c03..a379aeba 100644 --- a/extension/third_party/CMakeLists.txt +++ b/extension/third_party/CMakeLists.txt @@ -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 # exist on the Android NDK or iOS SDK sysroots, so the mobile bring-up (BACKLOG # F1/F2) needs them built from source as part of our own build. All three are -# pinned here and produce static libs that link into libmtgodot: +# pinned here and produce static libraries for port_platform: # # sodium <- libsodium-cmake submodule (wraps jedisct1/libsodium) # 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 # 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) # --- libsodium ------------------------------------------------------------------ @@ -57,7 +57,7 @@ if(WIN32) else() set(MT_EMBED_PYTHON_DEFAULT ON) endif() -option(MTGODOT_EMBED_PYTHON "Build the embedded CPython 2.7.18 static library" ${MT_EMBED_PYTHON_DEFAULT}) -if(MTGODOT_EMBED_PYTHON) +option(MT_EMBED_PYTHON "Build the embedded CPython 2.7.18 static library" ${MT_EMBED_PYTHON_DEFAULT}) +if(MT_EMBED_PYTHON) add_subdirectory(cpython-2.7.18) endif() diff --git a/gen-debug-keystore.sh b/gen-debug-keystore.sh deleted file mode 100755 index 3fcb1c7a..00000000 --- a/gen-debug-keystore.sh +++ /dev/null @@ -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)." diff --git a/macos.entitlements b/macos.entitlements deleted file mode 100644 index 8cc185af..00000000 --- a/macos.entitlements +++ /dev/null @@ -1,8 +0,0 @@ - - - - - com.apple.security.cs.disable-library-validation - - - diff --git a/native_render/README.md b/native_render/README.md index 0b2b7542..1ff60377 100644 --- a/native_render/README.md +++ b/native_render/README.md @@ -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 @@ -9,18 +9,9 @@ Install Vulkan headers/loader, MoltenVK, SDL3 and `glslc` (shaderc). With Homebr ```sh brew install vulkan-headers vulkan-loader molten-vk sdl3 shaderc -# 1. Standalone replay build (without port_platform) -cmake -S . -B build-native-render -DMTGODOT_BUILD_EXTENSION=OFF \ - -DMT_BUILD_NATIVE_RENDER_PROTOTYPE=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 +# Optimized Release build (includes port_platform, embedded Python and FakeLoginServer) +cmake -S . -B build-release -DCMAKE_BUILD_TYPE=Release -DMT_BUILD_NATIVE_RENDER=ON \ + -DMT_EMBED_PYTHON=ON -DCMAKE_PREFIX_PATH=/opt/homebrew cmake --build build-release --target mt_native_render -j8 ``` diff --git a/native_render/main.cpp b/native_render/main.cpp index cdc74dd3..e99b2c9d 100644 --- a/native_render/main.cpp +++ b/native_render/main.cpp @@ -9,6 +9,7 @@ #include "platform/PackBackend.h" #include "platform/ScriptLib/PythonBoot.h" #include "platform/UserInterface/ServerClock.h" +#include "platform/EterBase/TraceErrorObserver.h" #include "../tests/port_login_flow_server.h" #include #endif @@ -23,6 +24,7 @@ #ifdef __APPLE__ #include +#include #endif #include @@ -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)); } -mtgodot::Image decode_tga(const std::uint8_t* data, std::size_t size) { - mtgodot::Image out; +mtimage::Image decode_tga(const std::uint8_t* data, std::size_t size) { + mtimage::Image out; if (size < 18) return out; const std::uint8_t id_len = data[0]; 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; } -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[0] == 'M' && data[1] == 'T' && data[2] == 'R' && data[3] == 'A') { 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); 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) { - mtgodot::Image out; + mtimage::Image out; out.w = static_cast(w); out.h = static_cast(h); 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 {}; } 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); if (tga.ok()) return tga; if (size > static_cast(INT_MAX)) return {}; int w = 0, h = 0, channels = 0; stbi_uc* pixels = stbi_load_from_memory(data, static_cast(size), &w, &h, &channels, 4); if (pixels && w > 0 && h > 0 && w <= 4096 && h <= 4096) { - mtgodot::Image out; + mtimage::Image out; out.w = static_cast(w); out.h = static_cast(h); out.rgba.assign(pixels, pixels + std::size_t(w) * std::size_t(h) * 4); @@ -847,6 +849,7 @@ public: } #endif if (!SDL_Init(SDL_INIT_VIDEO)) throw std::runtime_error(SDL_GetError()); + SDL_SetHint(SDL_HINT_ORIENTATIONS, "LandscapeLeft LandscapeRight"); window_ = SDL_CreateWindow( "Metin2 Native Vulkan Client", std::max(init_width, 320), @@ -979,6 +982,7 @@ public: screenshot_path_ = std::move(path); screenshot_frame_ = frame; } + std::uint64_t frame_number() const { return frame_number_; } int msaa_samples() const { return int(samples_); } std::uint32_t width() const { return extent_.width; } @@ -986,11 +990,19 @@ public: std::uint32_t logical_width() const { int w = 0, h = 0; 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(w) : extent_.width; } std::uint32_t logical_height() const { int w = 0, h = 0; if (window_) SDL_GetWindowSize(window_, &w, &h); +#ifdef __ANDROID__ + if (w > 0 && h > 0) return 800; +#endif return h > 0 ? static_cast(h) : extent_.height; } @@ -1192,10 +1204,10 @@ public: if (event.type == SDL_EVENT_WINDOW_RESIZED || event.type == SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED) { int ew = 0, eh = 0; 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 if (forward_to_live_client && ew > 0 && eh > 0) { - PythonBoot::SetUISize(ew, eh); + PythonBoot::SetUISize(int(logical_width()), int(logical_height())); } #endif recreate_swapchain(); @@ -1658,9 +1670,12 @@ public: present.waitSemaphoreCount = 1; present.pWaitSemaphores = &rendered_; present.swapchainCount = 1; present.pSwapchains = &swapchain_; present.pImageIndices = &image_index; 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(); - } else if (presented != VK_SUCCESS) { + } else if (presented != VK_SUCCESS && presented != VK_SUBOPTIMAL_KHR) { check(presented, "vkQueuePresentKHR"); } 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.imageArrayLayers = 1; info.imageUsage = VK_IMAGE_USAGE_COLOR_ATTACHMENT_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.clipped = VK_TRUE; check(vkCreateSwapchainKHR(device_, &info, nullptr, &swapchain_), "vkCreateSwapchainKHR"); @@ -2617,7 +2642,7 @@ private: } 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()) { 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"); } +#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) { const auto p1 = spec.find(':'); if (p1 == std::string::npos) return false; @@ -3206,15 +3257,25 @@ int run_live_client( bool gpu_skinning, bool native_terrain, bool login_screen, + bool selection_screen, 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) { const std::string mobs_str = std::to_string(fake_mobs); 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] = {}; 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); +#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(".")) { throw std::runtime_error("cannot initialize 40250 pack at: " + abs_client_dir); } @@ -3226,6 +3287,9 @@ int run_live_client( SetNativeTerrainRenderEnabled(native_terrain); SetGpuSkinningEnabled(gpu_skinning); NativeAudioEngine audio; +#ifdef __ANDROID__ + SDL_Log("live stage: audio initialized"); +#endif std::string server_host = "127.0.0.1"; int auth_port = 0; @@ -3271,11 +3335,17 @@ int run_live_client( #endif if (!PythonBoot::Start(stdlib.c_str(), &error)) throw std::runtime_error("PythonBoot::Start: " + error); +#ifdef __ANDROID__ + SDL_Log("live stage: PythonBoot started"); +#endif SetPlatformServerTime(123456789); PythonBoot::SetUISize(static_cast(renderer.logical_width()), static_cast(renderer.logical_height())); if (!PythonBoot::RunMainScript("", &error) || !PythonBoot::IsAppLooping()) throw std::runtime_error("PythonBoot::RunMainScript: " + error); +#ifdef __ANDROID__ + SDL_Log("live stage: main script started"); +#endif const std::unordered_map> empty_textures; 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)"); })) throw std::runtime_error("timed out waiting for LoginWindow"); +#ifdef __ANDROID__ + SDL_Log("live stage: LoginWindow open"); +#endif if (login_screen) { char conn_cmd[512]; @@ -3338,18 +3411,28 @@ int run_live_client( auth_port, FakeLoginServer::kLogin, FakeLoginServer::kPassword); +#ifdef __ANDROID__ + SDL_Log("live stage: submitting fake login"); +#endif if (!py_exec(login_cmd)) 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, [&] { return (use_external_server || server.Has("game1:login2")) && py_eval_true("isinstance(_stream.curPhaseWindow, introSelect.SelectCharacterWindow)"); })) throw std::runtime_error("timed out waiting for SelectCharacterWindow"); +#ifdef __ANDROID__ + SDL_Log("live stage: SelectCharacterWindow open"); +#endif - if (!py_exec("_stream.curPhaseWindow.SelectSlot(0)\n" - "_stream.curPhaseWindow.StartGame()")) - throw std::runtime_error("failed to start game from SelectCharacterWindow"); + if (!selection_screen) { + if (!py_exec("_stream.curPhaseWindow.SelectSlot(0)\n" + "_stream.curPhaseWindow.StartGame()")) + throw std::runtime_error("failed to start game from SelectCharacterWindow"); if (!use_external_server) { if (!pump_until(30.0, 5, [&] { return server.Has("game2:client_version"); })) @@ -3362,12 +3445,22 @@ int run_live_client( })) throw std::runtime_error("timed out waiting for GameWindow (server error: " + server.Error() + ")"); - const int warmup_frames = std::max(10, fake_mobs / 2 + 10); - for (int i = 0; i < warmup_frames; ++i) { - if (!renderer.poll(true) || !PythonBoot::IsAppLooping()) break; - PythonBoot::UIUpdate(); - renderer.sync_game_cursor(); - audio.pump(); +#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); + for (int i = 0; i < warmup_frames; ++i) { + if (!renderer.poll(true) || !PythonBoot::IsAppLooping()) break; + PythonBoot::UIUpdate(); + renderer.sync_game_cursor(); + audio.pump(); + } } } @@ -3417,6 +3510,10 @@ int run_live_client( 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(frames)); + } + renderer.reset_timings(); const auto start = std::chrono::steady_clock::now(); double update_ms = 0.0; @@ -3442,10 +3539,16 @@ int run_live_client( ++completed; } renderer.finish_gpu_timings(); +#ifdef __ANDROID__ + SDL_Log("live stage: render loop complete (%d frames)", completed); +#endif const auto wall_ms = std::chrono::duration(std::chrono::steady_clock::now() - start).count(); print_summary(renderer, completed, wall_ms, update_ms, std::move(frame_ms)); PythonBoot::Stop(); +#ifdef __ANDROID__ + SDL_Log("live stage: PythonBoot stopped"); +#endif if (!use_external_server) server.Stop(); return (frames == 0 || completed == frames) ? 0 : 2; @@ -3461,6 +3564,7 @@ int main(int argc, char** argv) { bool synthetic_specified = false; bool interactive = false; bool login_screen = false; + bool selection_screen = false; int init_width = 1280; int init_height = 800; std::size_t draw_count = 64; @@ -3489,6 +3593,9 @@ int main(int argc, char** argv) { else if (arg == "--login-screen") { login_screen = true; interactive = true; + } else if (arg == "--selection-screen") { + selection_screen = true; + interactive = true; } else if (arg == "--live-server" && i+1 < argc) { live_server_spec = argv[++i]; interactive = true; @@ -3525,7 +3632,7 @@ int main(int argc, char** argv) { else if (arg == "--no-terrain") native_terrain = false; else if (arg == "--mobile") mobile_mode = true; 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] " "[--draws N] [--triangles-per-draw N] [--capture FILE] [--animate-first-draw] " "[--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; +#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 || triangles_per_draw == 0 || triangles_per_draw > 10000) throw std::runtime_error("invalid frame/draw/triangle count"); @@ -3569,7 +3682,7 @@ int main(int argc, char** argv) { } if (!live_client_dir.empty()) { #ifdef MT_NATIVE_HAS_LIVE_CLIENT - return run_live_client( + const int result = run_live_client( renderer, live_client_dir, frames, @@ -3577,8 +3690,14 @@ int main(int argc, char** argv) { gpu_skinning, native_terrain, login_screen, + selection_screen, live_server_spec, - capture_out); + capture_out, + screenshot_out); +#ifdef __ANDROID__ + SDL_Log("native live client exit: %d", result); +#endif + return result; #else throw std::runtime_error("mt_native_render was built without port_platform (--live-client unavailable)"); #endif @@ -3620,6 +3739,9 @@ int main(int argc, char** argv) { return (frames == 0 || completed == frames) ? 0 : 2; } catch (const std::exception& error) { std::cerr << "native renderer: " << error.what() << '\n'; +#ifdef __ANDROID__ + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "native renderer: %s", error.what()); +#endif return 1; } } diff --git a/native_render/touch_controller.h b/native_render/touch_controller.h index 62150e84..79608940 100644 --- a/native_render/touch_controller.h +++ b/native_render/touch_controller.h @@ -17,9 +17,7 @@ class TouchController { public: struct ButtonDef { - int id; // 1 = ATK, 2 = S1, 3 = S2, 4 = S3, 5 = POT, 6 = PICK - // 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 id; // 1 = attack, 2..7 = quick slots 1..6 int dik; float x, y, radius; const char* label; @@ -53,6 +51,14 @@ public: // Called once per frame to maintain continuous analog movement void update() { 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_) { #ifdef MT_NATIVE_HAS_LIVE_CLIENT PythonBoot::SetMoveDirection(move_angle_, true); @@ -65,23 +71,25 @@ public: if (!enabled_) return false; const float px = norm_x * float(screen_w_); const float py = norm_y * float(screen_h_); + const bool controls_visible = gameplay_controls_visible(); - // 1. Check HUD buttons (Action cluster & Drawer menu) - int btn_idx = find_button(px, py); - if (btn_idx >= 0) { - auto& btn = buttons_[btn_idx]; - if (btn.id == 99) { // MENU toggle button - drawer_open_ = !drawer_open_; - layout_buttons(); + // The mobile overlay owns only the movement and combat control regions. + if (controls_visible) { + const int btn_idx = find_button(px, py); + if (btn_idx >= 0) { + auto& btn = buttons_[btn_idx]; +#ifdef MT_NATIVE_HAS_LIVE_CLIENT + if (btn.id >= 2 && PythonBoot::TryAssignAttachedObjectToLocalQuickSlot(btn.id - 2)) + return true; +#endif + btn.pressed = true; + btn.finger_id = finger_id; + trigger_button(btn.dik, true); return true; } - btn.pressed = true; - btn.finger_id = finger_id; - trigger_button(btn.dik, 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 if (PythonBoot::IsPointInsideActiveUI(int(px), int(py))) { ui_touch_finger_id_ = finger_id; @@ -91,8 +99,8 @@ public: } #endif - // 3. Left side: virtual joystick (lower-left quadrant) - if (norm_x < 0.40f && norm_y > 0.35f && !joystick_active_) { + // Start movement only in the gameplay phase. + if (controls_visible && norm_x < 0.40f && norm_y > 0.35f && !joystick_active_) { joystick_active_ = true; joystick_finger_id_ = finger_id; joystick_base_x_ = px; @@ -103,7 +111,10 @@ public: 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) { camera_finger_id_ = finger_id; camera_last_x_ = px; @@ -265,80 +276,12 @@ public: 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& commands) const { - if (!enabled_) return; + if (!enabled_ || !gameplay_controls_visible()) return; - const float W = float(screen_w_); - -#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) --- + // Right-side attack and quick-slot 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; 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); @@ -346,6 +289,24 @@ public: const uint32_t icon_col = btn.pressed ? 0xFFFFFFFF : 0xDDFFFFFF; 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) { case 1: { // ATK: crossed swords 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); break; } - case 5: { // POT: + - const float p = r * 0.4f; - draw_line(commands, btn.x - p, btn.y, btn.x + p, btn.y, icon_col); - draw_line(commands, btn.x, btn.y - p, btn.x, btn.y + p, icon_col); + case 5: { // S4: IV + const float h = r * 0.35f; + 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 - 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; } - case 6: { // PICK: Downward arrow - const float a = 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 + a, btn.y - a * 0.3f, btn.x, btn.y + a * 0.6f, icon_col); - draw_line(commands, btn.x, btn.y - a * 0.7f, btn.x, btn.y + a * 0.6f, icon_col); + case 6: { // S5: V + const float h = r * 0.35f; + draw_line(commands, btn.x - 8.0f, btn.y - h, btn.x, btn.y + h, icon_col); + draw_line(commands, btn.x + 8.0f, btn.y - h, btn.x, btn.y + h, icon_col); break; } - case 99: { // MENU: ☰ hamburger icon - 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 - 7.0f, btn.y, btn.x + 7.0f, btn.y, icon_col); - draw_line(commands, btn.x - 7.0f, btn.y + 5.0f, btn.x + 7.0f, btn.y + 5.0f, icon_col); - break; - } - 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); + case 7: { // S6: VI + const float h = r * 0.35f; + draw_line(commands, btn.x - 11.0f, btn.y - h, btn.x - 3.0f, btn.y + h, icon_col); + draw_line(commands, btn.x + 5.0f, btn.y - h, btn.x - 3.0f, btn.y + h, icon_col); + draw_line(commands, btn.x + 11.0f, btn.y - h, btn.x + 11.0f, btn.y + h, icon_col); 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_ * 0.45f, 0x4080C0FF, 16); draw_line(commands, joystick_base_x_ - joystick_radius_, joystick_base_y_, @@ -494,26 +416,52 @@ public: } 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() { 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({2, 0x02, 0, 0, 26.0f, "S1", 0x803060C0, 0xD05080FF}); 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({5, 0x05, 0, 0, 22.0f, "POT", 0x90A03030, 0xD0FF5050}); - buttons_.push_back({6, 0x2c, 0, 0, 22.0f, "PICK", 0x80208080, 0xD040B0B0}); - - // 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 + buttons_.push_back({5, 0x05, 0, 0, 26.0f, "S4", 0x80906030, 0xD0D08050}); + buttons_.push_back({6, 0x06, 0, 0, 26.0f, "S5", 0x80603090, 0xD08050D0}); + buttons_.push_back({7, 0x07, 0, 0, 26.0f, "S6", 0x80308090, 0xD050C0D0}); } void layout_buttons() { @@ -522,70 +470,40 @@ private: for (auto& btn : buttons_) { switch (btn.id) { case 1: - btn.x = W - 85.0f; + btn.x = W - 75.0f; btn.y = H - 85.0f; btn.radius = std::min(46.0f, H * 0.12f); break; 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.y = H - 85.0f; btn.radius = std::min(28.0f, H * 0.08f); break; - case 3: - btn.x = W - 145.0f; + case 6: + btn.x = W - 125.0f; btn.y = H - 155.0f; btn.radius = std::min(28.0f, H * 0.08f); break; - case 4: - btn.x = W - 85.0f; - btn.y = H - 175.0f; + case 7: + btn.x = W - 75.0f; + btn.y = H - 225.0f; btn.radius = std::min(28.0f, H * 0.08f); 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) { for (size_t i = 0; i < buttons_.size(); ++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); if (dist <= btn.radius * 1.25f) { return int(i); @@ -608,6 +524,11 @@ private: if (dik == 0x39) { PythonBoot::SetAttackKey(pressed); } + if (dik >= 0x02 && dik <= 0x07) { + if (pressed) + PythonBoot::UseLocalQuickSlot(dik - 0x02); + return; + } if (dik != 0) { PythonBoot::UIKey(dik, pressed); } @@ -703,7 +624,7 @@ private: int screen_w_ = 1280; int screen_h_ = 720; - bool drawer_open_ = false; + bool controls_were_visible_ = false; int64_t ui_touch_finger_id_ = -1; bool joystick_active_ = false; diff --git a/oracle/run-diff-suite.sh b/oracle/run-diff-suite.sh index 68ea11f1..0aa16f5b 100755 --- a/oracle/run-diff-suite.sh +++ b/oracle/run-diff-suite.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # M2 门禁·主:对测试资产集跑 oracle dump + oracle_diff,汇总 test/m2-numeric.json。 # 前置: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 cd "$(dirname "$0")/.." REPO="$(pwd)" @@ -11,7 +11,7 @@ DIFF="" for c in "$REPO/build-tools/tools/oracle_diff" "$REPO/build/tools/oracle_diff"; do [ -x "$c" ] && DIFF="$c" && break 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" WINE="${WINE:-/tmp/wine/Wine Stable.app/Contents/Resources/wine/bin/wine}" diff --git a/pack-assets.sh b/pack-assets.sh deleted file mode 100755 index c50b2bdb..00000000 --- a/pack-assets.sh +++ /dev/null @@ -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/" diff --git a/project/asset_pack.gd b/project/asset_pack.gd deleted file mode 100644 index 2b5bd7c4..00000000 --- a/project/asset_pack.gd +++ /dev/null @@ -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//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 "" diff --git a/project/asset_pack.gd.uid b/project/asset_pack.gd.uid deleted file mode 100644 index 002f9a6d..00000000 --- a/project/asset_pack.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://f260rrsoxa0p diff --git a/project/asset_root.gd b/project/asset_root.gd deleted file mode 100644 index bfbdd942..00000000 --- a/project/asset_root.gd +++ /dev/null @@ -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") 注册 /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/"), -# 走 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 "" diff --git a/project/asset_root.gd.uid b/project/asset_root.gd.uid deleted file mode 100644 index d4237866..00000000 --- a/project/asset_root.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://debiskaw74tdw diff --git a/project/assets/.gdignore b/project/assets/.gdignore deleted file mode 100644 index e69de29b..00000000 diff --git a/project/audio_driver.gd b/project/audio_driver.gd deleted file mode 100644 index bd84fc5c..00000000 --- a/project/audio_driver.gd +++ /dev/null @@ -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) diff --git a/project/audio_driver.gd.uid b/project/audio_driver.gd.uid deleted file mode 100644 index 25611f98..00000000 --- a/project/audio_driver.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://vqw8iy17y4m0 diff --git a/project/audio_driver_test.gd b/project/audio_driver_test.gd deleted file mode 100644 index c9022fc0..00000000 --- a/project/audio_driver_test.gd +++ /dev/null @@ -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) diff --git a/project/audio_driver_test.gd.uid b/project/audio_driver_test.gd.uid deleted file mode 100644 index f2561ac6..00000000 --- a/project/audio_driver_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dfbxpg1q8a0q8 diff --git a/project/beta_live_probe.gd b/project/beta_live_probe.gd deleted file mode 100644 index d4243910..00000000 --- a/project/beta_live_probe.gd +++ /dev/null @@ -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) diff --git a/project/beta_live_probe.gd.uid b/project/beta_live_probe.gd.uid deleted file mode 100644 index f26bfc43..00000000 --- a/project/beta_live_probe.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://b0y8dh6cv657q diff --git a/project/bin/mtgodot.gdextension b/project/bin/mtgodot.gdextension deleted file mode 100644 index b5b3f084..00000000 --- a/project/bin/mtgodot.gdextension +++ /dev/null @@ -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" : "" -} diff --git a/project/bin/mtgodot.gdextension.uid b/project/bin/mtgodot.gdextension.uid deleted file mode 100644 index 8e6f1ae3..00000000 --- a/project/bin/mtgodot.gdextension.uid +++ /dev/null @@ -1 +0,0 @@ -uid://hc7fxxrjvpr8 diff --git a/project/client_main.gd b/project/client_main.gd deleted file mode 100644 index d569c6a1..00000000 --- a/project/client_main.gd +++ /dev/null @@ -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) diff --git a/project/client_main.gd.uid b/project/client_main.gd.uid deleted file mode 100644 index fd3d5448..00000000 --- a/project/client_main.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://d384dorgv24yv diff --git a/project/client_main.tscn b/project/client_main.tscn deleted file mode 100644 index cb09347d..00000000 --- a/project/client_main.tscn +++ /dev/null @@ -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") diff --git a/project/debug_actor_mesh.gd b/project/debug_actor_mesh.gd deleted file mode 100644 index 936cf871..00000000 --- a/project/debug_actor_mesh.gd +++ /dev/null @@ -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) diff --git a/project/debug_actor_mesh.gd.uid b/project/debug_actor_mesh.gd.uid deleted file mode 100644 index 4b4ad01a..00000000 --- a/project/debug_actor_mesh.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://b5vx3h3lipsuf diff --git a/project/debug_anomalies.gd b/project/debug_anomalies.gd deleted file mode 100644 index 859cb7ad..00000000 --- a/project/debug_anomalies.gd +++ /dev/null @@ -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) diff --git a/project/debug_anomalies.gd.uid b/project/debug_anomalies.gd.uid deleted file mode 100644 index ff597280..00000000 --- a/project/debug_anomalies.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://xn0yhjq7h8d diff --git a/project/debug_height.gd b/project/debug_height.gd deleted file mode 100644 index cfcbc4bb..00000000 --- a/project/debug_height.gd +++ /dev/null @@ -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) diff --git a/project/debug_height.gd.uid b/project/debug_height.gd.uid deleted file mode 100644 index ffc89dd1..00000000 --- a/project/debug_height.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://mpcddwfiipkc diff --git a/project/debug_select_screen.gd b/project/debug_select_screen.gd deleted file mode 100644 index d14d9c3b..00000000 --- a/project/debug_select_screen.gd +++ /dev/null @@ -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) diff --git a/project/debug_select_screen.gd.uid b/project/debug_select_screen.gd.uid deleted file mode 100644 index c511e1c0..00000000 --- a/project/debug_select_screen.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://c0k854rmu55u7 diff --git a/project/debug_stones.gd b/project/debug_stones.gd deleted file mode 100644 index da3aafca..00000000 --- a/project/debug_stones.gd +++ /dev/null @@ -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) diff --git a/project/debug_stones.gd.uid b/project/debug_stones.gd.uid deleted file mode 100644 index 035e9be4..00000000 --- a/project/debug_stones.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cy6rh17pn00hd diff --git a/project/debug_weapon_motion.gd b/project/debug_weapon_motion.gd deleted file mode 100644 index 95caa15a..00000000 --- a/project/debug_weapon_motion.gd +++ /dev/null @@ -1,115 +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, app\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()" - )) - - print("=== IN-GAME LOADED ===") - for i in 10: - await process_frame - - print("--- EQUIPPING WEAPON 10 ---") - _py("chr.SelectInstance(player.GetMainCharacterIndex())\nchr.SetWeapon(10)") - for i in 10: - await process_frame - - var check_draws = func(label: String): - var draws: Array = Metin2PythonHost.render3d_draws() - var body_draw = null - var weapon_draws = [] - for d in draws: - var tex: String = d.get("texture0", "").to_lower() - if tex.contains("warrior") and not tex.contains("hair"): - body_draw = d - elif tex.contains("weapon") or tex.contains("sword") or tex.contains("00010") or tex.contains("00040"): - weapon_draws.append(d) - print("[%s] Total draws=%d, weapon_draws=%d" % [label, draws.size(), weapon_draws.size()]) - var b_xf: Transform3D = Transform3D() - if body_draw != null: - var wm: PackedFloat32Array = body_draw["world"] - b_xf = Python3DSurface.d3d_transform(wm) - print(" Body origin: ", b_xf.origin, " tex=", body_draw.get("texture0", "")) - else: - print(" Body draw not found!") - for w in weapon_draws: - var wm: PackedFloat32Array = w["world"] - var xf: Transform3D = Python3DSurface.d3d_transform(wm) - var dist := xf.origin.distance_to(b_xf.origin) if body_draw != null else 0.0 - print(" Weapon origin: ", xf.origin, " dist_to_body=%.2f cm tex=%s" % [dist, w.get("texture0", "")]) - - check_draws.call("IDLE_FRAME_0") - - # Start moving! - print("--- STARTING MOVEMENT (DIK_UP) ---") - _py("player.SetSingleDIKKeyState(app.DIK_UP, True)") - - for frame in range(1, 40): - await process_frame - if frame % 5 == 0: - check_draws.call("MOVING_FRAME_%d" % frame) - - _py("player.SetSingleDIKKeyState(app.DIK_UP, False)") - for frame in range(1, 15): - await process_frame - check_draws.call("STOPPED") - - quit(0) diff --git a/project/export_presets.cfg b/project/export_presets.cfg deleted file mode 100644 index 49c0da14..00000000 --- a/project/export_presets.cfg +++ /dev/null @@ -1,128 +0,0 @@ -[preset.0] - -name="macOS" -platform="macOS" -runnable=true -advanced_options=false -dedicated_server=false -custom_features="" -export_filter="all_resources" -include_filter="python27.zip,python27.zip.sha256" -exclude_filter="" -export_path="../build/export/mtgodot-poc.app" -patches=PackedStringArray() -encryption_include_filters="" -encryption_exclude_filters="" -seed=0 -encrypt_pck=false -encrypt_directory=false -script_export_mode=2 - -[preset.0.options] - -export/distribution_type=1 -binary_format/architecture="universal" -custom_template/debug="" -custom_template/release="" -debug/export_console_wrapper=1 -application/icon="" -application/icon_interpolation=4 -application/bundle_identifier="org.internal.mtgodotpoc" -application/signature="" -application/app_category="Games" -application/short_version="1.0" -application/version="1.0" -application/copyright="" -display/high_res=true -xcode/platform_build="14C18" -xcode/sdk_version="13.1" -xcode/sdk_path="/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk" -xcode/sdk_name="macosx13.1" -codesign/codesign=0 -codesign/identity="" -codesign/certificate_file="" -codesign/certificate_password="" -codesign/entitlements/allow_jit_code_execution=false -codesign/entitlements/allow_unsigned_executable_memory=false -codesign/entitlements/allow_dyld_environment_variables=false -codesign/entitlements/disable_library_validation=true -notarization/notarization=0 -privacy/microphone_usage_description="" -privacy/camera_usage_description="" -privacy/location_usage_description="" -privacy/address_book_usage_description="" -privacy/calendar_usage_description="" -privacy/photos_library_usage_description="" -privacy/desktop_folder_usage_description="" -privacy/documents_folder_usage_description="" -privacy/downloads_folder_usage_description="" -privacy/network_volumes_usage_description="" -privacy/removable_volumes_usage_description="" -ssh_remote_deploy/enabled=false - -[preset.1] - -name="Android" -platform="Android" -runnable=true -advanced_options=false -dedicated_server=false -custom_features="" -export_filter="all_resources" -include_filter="python27.zip,python27.zip.sha256" -exclude_filter="" -export_path="../build/export/mtgodot-poc.apk" -patches=PackedStringArray() -encryption_include_filters="" -encryption_exclude_filters="" -seed=0 -encrypt_pck=false -encrypt_directory=false -script_export_mode=2 - -[preset.1.options] - -custom_template/debug="" -custom_template/release="" -gradle_build/use_gradle_build=true -gradle_build/gradle_build_directory="res://android" -gradle_build/android_source_template="" -gradle_build/compress_native_libraries=false -gradle_build/export_format=0 -gradle_build/min_sdk="" -gradle_build/target_sdk="" -architectures/armeabi-v7a=false -architectures/arm64-v8a=true -architectures/x86=false -architectures/x86_64=false -version/code=1 -version/name="1.0" -package/unique_name="org.internal.mtgodotpoc" -package/name="" -package/signed=true -package/app_category=2 -package/retain_data_on_uninstall=false -package/exclude_from_recents=false -package/show_in_android_tv=false -package/show_in_app_library=true -package/show_as_launcher_app=false -launcher_icons/main_192x192="" -launcher_icons/adaptive_foreground_432x432="" -launcher_icons/adaptive_background_432x432="" -launcher_icons/adaptive_monochrome_432x432="" -graphics/opengl_debug=false -xr_features/xr_mode=0 -screen/immersive_mode=true -screen/support_small=true -screen/support_normal=true -screen/support_large=true -screen/support_xlarge=true -user_data_backup/allow=false -command_line/extra_args="res://client_main.tscn" -apk_expansion/enable=false -apk_expansion/SALT="" -apk_expansion/public_key="" -permissions/internet=true -permissions/access_network_state=true -permissions/access_wifi_state=true -permissions/wake_lock=true diff --git a/project/particle_exit_test.gd b/project/particle_exit_test.gd deleted file mode 100644 index b7ba30db..00000000 --- a/project/particle_exit_test.gd +++ /dev/null @@ -1,39 +0,0 @@ -# Export-template reproducer: no maps, models, assets or network required. -extends Node -var held: Array[ParticleProcessMaterial] = [] -func _ready() -> void: - call_deferred("run") -func run() -> void: - var mode := OS.get_environment("MT_PARTICLE_EXIT_CASE") - for i in 8: - make_particle(mode, i) - for i in 5: await get_tree().process_frame - held.clear() - for i in 5: await get_tree().process_frame - print("PARTICLE_EXIT PASS case=", mode) - get_tree().quit() -func make_particle(mode: String, index: int) -> void: - if mode == "empty": return - var material := ParticleProcessMaterial.new() - if mode in ["gravity", "scale", "shape", "held", "restored"]: - if mode == "gravity": material.gravity = Vector3(0, -1, 0) - if mode == "scale": material.scale_min = 0.5 - if mode in ["shape", "held", "restored"]: material.emission_shape = index % 3 - material.get_rid() - if mode == "restored": - material.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_POINT - material.get_rid() - if mode == "held": held.append(material) - return - if mode == "primed": material.get_rid() - if mode != "default": - material.gravity = Vector3(0, -1, 0) - material.emission_shape = index % 3 - material.scale_min = 0.5 - material.get_rid() - if mode == "emitter": - var emitter := GPUParticles3D.new() - emitter.emitting = false - emitter.process_material = material - add_child(emitter) - emitter.free() diff --git a/project/particle_exit_test.gd.uid b/project/particle_exit_test.gd.uid deleted file mode 100644 index c4b7c584..00000000 --- a/project/particle_exit_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://d16lqtrehlaet diff --git a/project/project.godot b/project/project.godot deleted file mode 100644 index 5e5794f2..00000000 --- a/project/project.godot +++ /dev/null @@ -1,42 +0,0 @@ -; Godot 4.7 project — mtgodot-poc Phase 1 (macOS) -; See ../docs/GODOT-POC-PLAN.md - -config_version=5 - -[application] - -config/name="mtgodot-poc" -; 完整客户端入口(登录→选人→进游戏)。旧的模型查看器 harness 仍可跑: -; godot --path project res://main.tscn -run/main_scene="res://client_main.tscn" -config/features=PackedStringArray("4.7", "Forward Plus") - -[debug] - -gdscript/warnings/untyped_declaration=1 - -[input_devices] - -; F7: game_camera.gd / player_controller.gd handle InputEventScreenTouch/Drag -; directly on device — don't also synthesize mouse events from touch (would -; double-fire tap-to-move). Desktop still uses real mouse events. -pointing/emulate_mouse_from_touch=false - -[mt] - -ui/profile="auto" -ui/mobile_scale=1.0 -ui/show_touch_debug=false - -[display] - -window/handheld/orientation=0 - -window/size/viewport_width=1920 -window/size/viewport_height=1080 - -[rendering] - -renderer/rendering_method="forward_plus" -anti_aliasing/quality/msaa_3d=2 -textures/vram_compression/import_etc2_astc=true diff --git a/project/python_3d_surface.gd b/project/python_3d_surface.gd deleted file mode 100644 index 84a1788d..00000000 --- a/project/python_3d_surface.gd +++ /dev/null @@ -1,450 +0,0 @@ -# 3D adapter for the ported game render (docs/PORT-PLAN.md 2V2-e): every frame it turns the draw calls -# CPythonApplication::RenderGame issued through CStateManager (Metin2PythonHost.render3d_draws, recorded -# by the platform IDirect3DDevice8) into meshes under a camera. -# -# The draws carry 40250's own D3D matrices. CGraphicBase::SetPerspective builds a right-handed -# projection (D3DXMatrixPerspectiveFovRH), so 40250's view space already is Godot's camera space -# (x right, y up, looking down -z): each mesh gets world * view as its transform and the Camera3D stays -# at the origin with the fov/near/far read back from the projection. No axis conversion is involved. -# D3D's default front face (clockwise on screen, D3DCULL_CCW culls the rest) is also Godot's. -# -# Terrain and lighting come from the same 40250 map pack as the actor textures. The map is placed -# in the recorded D3D camera's coordinate frame, and its .msenv supplies the character light. -extends Node3D - -const AssetRoot = preload("res://asset_root.gd") -const UiAssets = preload("res://ui/ui_assets.gd") - -# D3DCULL / D3DBLEND / D3DCMPFUNC values used below (D3D8Types.h). -const D3DCULL_NONE := 1 -const D3DCULL_CW := 2 -const D3DCULL_CCW := 3 -const D3DBLEND_ONE := 2 -const D3DBLEND_SRCALPHA := 5 -const D3DBLEND_INVSRCALPHA := 6 - -var camera: Camera3D -var light: DirectionalLight3D -var environment: WorldEnvironment -var terrain: Node3D -var terrain_report := {} -var map_path := "metin2_map_a1" -var _atlas: Array = [] -var _terrain_attempted := false -var _last_terrain_focus := Vector2(INF, INF) -var _meshes: Array[MeshInstance3D] = [] -var _geometry_cache := {} -var _geometry_frame := 0 -var _bg_mesh: MeshInstance3D -var _bg_mat: StandardMaterial3D -var _bg_quad: QuadMesh -# Materials keyed by texture and state, so an unchanged draw keeps its material from frame to frame. -var _materials := {} - -# The draws the last update_frame() showed (for tests and reports). -var draw_count := 0 -var unlit_stand_in_count := 0 # Compatibility report field; real lighting keeps this at zero. - -func _ready() -> void: - get_viewport().transparent_bg = true - camera = Camera3D.new() - camera.name = "GameCamera" - camera.keep_aspect = Camera3D.KEEP_HEIGHT - camera.current = true - add_child(camera) - # 40250's directional light (CPythonBackground::SetCharacterDirLight) arrives with the draws in - # world space; it is placed in camera space each frame like the meshes. - light = DirectionalLight3D.new() - light.name = "Light0" - light.visible = false - light.light_cull_mask = 1 << 1 - add_child(light) - environment = WorldEnvironment.new() - environment.environment = Environment.new() - environment.environment.background_mode = Environment.BG_CLEAR_COLOR - environment.environment.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR - environment.environment.ambient_light_color = Color.BLACK - environment.environment.tonemap_mode = Environment.TONE_MAPPER_LINEAR - add_child(environment) - -func _process(_delta: float) -> void: - if Metin2PythonHost.is_running(): - var profile_start := Time.get_ticks_usec() if _profile_frame else 0 - update_frame() - if profile_start != 0: - profile_update_ms = float(Time.get_ticks_usec() - profile_start) / 1000.0 - -var _profile_frame := OS.has_environment("MT_PROFILE_FRAME") -var profile_update_ms := 0.0 - -# D3D row-vector matrix (v' = v * M) as a Godot Transform3D (v' = T * v): the rows of M are the basis -# columns, row 3 is the origin. -static func d3d_transform(m: PackedFloat32Array) -> Transform3D: - return Transform3D(Basis(Vector3(m[0], m[1], m[2]), Vector3(m[4], m[5], m[6]), Vector3(m[8], m[9], m[10])), - Vector3(m[12], m[13], m[14])) - -func update_frame() -> void: - _geometry_frame += 1 - var draws: Array = Metin2PythonHost.render3d_draws() - var native_map: String = Metin2PythonHost.current_map_name() - if terrain != null and not native_map.is_empty() and native_map != map_path: - terrain.queue_free() - terrain = null - terrain_report = {} - _terrain_attempted = false - _last_terrain_focus = Vector2(INF, INF) - var shown := 0 - var camera_set := false - var light_set := false - var terrain_view := PackedFloat32Array() - for draw in draws: - # XYZRHW (screen-space) draws belong to the 2D pass; lines are debug geometry. - if draw["pretransformed"] or draw["lines"] or draw["indices"].is_empty(): - continue - var proj: PackedFloat32Array = draw["proj"] - # _34 == -1: a perspective projection (the game pass). Ortho draws are UI-space. - if proj[11] != -1.0: - continue - if not camera_set: - _apply_projection(proj, draw) - camera_set = true - var view: PackedFloat32Array = draw["view"] - if terrain == null and not _terrain_attempted: - _load_terrain(draw) - if terrain != null: - terrain_view = view - if not light_set and draw["light0"]: - _apply_light(draw, view) - light_set = true - var instance := _mesh_instance(shown) - instance.transform = d3d_transform(view) * d3d_transform(draw["world"]) - instance.mesh = _mesh_for_draw(draw) - instance.material_override = _material(draw) - instance.sorting_offset = float(shown) * 0.01 - instance.visible = true - shown += 1 - if terrain != null and not terrain_view.is_empty(): - _place_terrain(terrain_view) - for i in range(shown, _meshes.size()): - _meshes[i].visible = false - _meshes[i].mesh = null - if not light_set: - light.visible = false - draw_count = shown - unlit_stand_in_count = 0 - _update_background() - if _geometry_frame % 120 == 0: - for key in _geometry_cache.keys(): - if _geometry_frame - int(_geometry_cache[key][2]) > 120: - _geometry_cache.erase(key) - -func _update_background() -> void: - var bg_cmd: Dictionary = {} - for cmd in Metin2PythonHost.ui_render_commands_batched(): - if cmd.get("behind_3d", false) and cmd.get("kind", "") == "image": - bg_cmd = cmd - break - if bg_cmd.is_empty(): - if _bg_mesh != null: - _bg_mesh.visible = false - return - - if _bg_mesh == null: - _bg_mesh = MeshInstance3D.new() - _bg_mesh.name = "BackgroundQuad" - _bg_mesh.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF - _bg_quad = QuadMesh.new() - _bg_mesh.mesh = _bg_quad - _bg_mat = StandardMaterial3D.new() - _bg_mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED - _bg_mat.depth_draw_mode = BaseMaterial3D.DEPTH_DRAW_ALWAYS - _bg_mat.cull_mode = BaseMaterial3D.CULL_DISABLED - _bg_mesh.material_override = _bg_mat - add_child(_bg_mesh) - - var bg_tex_name: String = bg_cmd.get("text", "") - var tex := UiAssets.load_tex(AssetRoot.path(), bg_tex_name) - if tex is AtlasTexture: - var at: AtlasTexture = tex - _bg_mat.albedo_texture = at.atlas - var asize: Vector2 = at.atlas.get_size() - var r: Rect2 = at.region - _bg_mat.uv1_scale = Vector3(r.size.x / asize.x, r.size.y / asize.y, 1.0) - _bg_mat.uv1_offset = Vector3(r.position.x / asize.x, r.position.y / asize.y, 0.0) - else: - _bg_mat.albedo_texture = tex - if bg_cmd.has("uv"): - var uvs: PackedVector2Array = bg_cmd["uv"] - _bg_mat.uv1_scale = Vector3(uvs[3].x - uvs[0].x, uvs[3].y - uvs[0].y, 1.0) - _bg_mat.uv1_offset = Vector3(uvs[0].x, uvs[0].y, 0.0) - else: - _bg_mat.uv1_scale = Vector3(1.0, 1.0, 1.0) - _bg_mat.uv1_offset = Vector3(0.0, 0.0, 0.0) - - var vp_size := get_viewport().get_visible_rect().size - var dist := 2800.0 - var v_size := 2.0 * dist * tan(deg_to_rad(camera.fov) / 2.0) - var h_size := v_size * (float(vp_size.x) / float(vp_size.y)) - _bg_quad.size = Vector2(h_size, v_size) - _bg_mesh.transform = Transform3D(Basis(), Vector3(0.0, 0.0, -dist)) - _bg_mesh.visible = true - -func _load_terrain(draw: Dictionary) -> void: - if not ClassDB.class_exists("Metin2World"): - return - var native_map: String = Metin2PythonHost.current_map_name() - if native_map.is_empty(): - return - var camera_world := d3d_transform(draw["view"]).affine_inverse().origin - var focus := Vector2(camera_world.x, -camera_world.y) - if focus.distance_to(_last_terrain_focus) < 1000.0: - return - _last_terrain_focus = focus - map_path = native_map - terrain = ClassDB.instantiate("Metin2World") - terrain.name = "PackTerrain" - terrain.set("auto_load", false) - terrain.set("assets_root", "pack://") - terrain.set("map_path", map_path) - terrain.set("load_radius_tiles", 1) - # One mesh per 256 m tile: split patches carry a visibility_range_end meant for metres, which in - # this cm scene culled every patch beyond 35 m (the map behind the actor vanished). - terrain.set("terrain_patches", 1) - terrain.set("objects_enabled", false) - terrain.set("water_enabled", false) - terrain.call("set_collision_enabled", false) - # A skinned mesh's world matrix can be local to a bone. The inverse view - # locates the camera beside the actor and selects the correct 3x3 map tiles. - # Native RenderGame coordinates are map-local; Metin2World's initial_focus_cm - # expects global AtlasInfo coordinates, so set the local tile directly. - terrain.call("set_focus_tile", Vector2i(int(floor(focus.x / 25600.0)), int(floor(focus.y / 25600.0)))) - add_child(terrain) - if not terrain.call("load_map"): - terrain_report = terrain.call("get_load_report") - push_error("40250 terrain failed: %s" % terrain_report.get("last_error", "")) - terrain.queue_free() - terrain = null - return - terrain_report = terrain.call("get_load_report") - if int(terrain_report.get("chunks_built", 0)) == 0: - # Loading's first frame can still contain the previous camera. Retry when - # __UpdateCamera moves to the main actor's map position. - terrain.queue_free() - terrain = null - terrain_report = {} - return - _terrain_attempted = true - # The map's own character light uses legacy Godot coordinates; the recorded game pass - # needs one light in camera space, derived from the same .msenv values below. - var map_character_light := terrain.get_node_or_null("CharacterLight") as DirectionalLight3D - if map_character_light: - map_character_light.visible = false - # WorldEnv from .msenv replaces the black environment created before map load. - if terrain_report.get("env_ok", false): - environment.environment = null - # Its depth fog is in legacy metres, but this scene is in cm, so it fogged everything past - # ~5 m and hid the terrain behind the actor. Fog belongs to the recorded D3DRS_FOG* state, - # which stays off until CPythonBackground::SetEnvironmentData is ported. - var map_environment := terrain.get_node_or_null("WorldEnv") as WorldEnvironment - if map_environment and map_environment.environment: - map_environment.environment.fog_enabled = false - -func _map_for_position(global_cm: Vector2) -> String: - if _atlas.is_empty(): - for path in ["locale/en/AtlasInfo.txt", "AtlasInfo.txt", "root/atlasinfo.txt"]: - var bytes: PackedByteArray = Metin2Pack.get_bytes(path) - if bytes.is_empty(): - continue - for line in bytes.get_string_from_utf8().split("\n"): - var fields := line.strip_edges().replace("\t", " ").split(" ", false) - if fields.size() < 5: - continue - _atlas.append({"name": fields[0].to_lower(), "base": Vector2(float(fields[1]), float(fields[2])), - "size": Vector2(float(fields[3]), float(fields[4])) * 25600.0}) - break - for entry in _atlas: - var local: Vector2 = global_cm - entry["base"] - if local.x >= 0.0 and local.y >= 0.0 and local.x < entry["size"].x \ - and local.y < entry["size"].y and Metin2Pack.exists(entry["name"] + "/setting.txt"): - return entry["name"] - return map_path - -func _place_terrain(view: PackedFloat32Array) -> void: - var camera_world := d3d_transform(view).affine_inverse().origin - var local_x := camera_world.x / 100.0 - var local_y := -camera_world.y / 100.0 - terrain.call("set_focus_position", local_x, local_y) - var from_map := Transform3D(Basis(Vector3(100, 0, 0), Vector3(0, 0, 100), - Vector3(0, -100, 0)), Vector3.ZERO) - terrain.transform = d3d_transform(view) * from_map - -func _apply_projection(proj: PackedFloat32Array, draw: Dictionary = {}) -> void: - # D3DXMatrixPerspectiveFovRH: _22 = cot(fovy / 2), _33 = zf / (zn - zf), _43 = zn * zf / (zn - zf). - var fov_deg := rad_to_deg(2.0 * atan(1.0 / proj[5])) - var zn: float = proj[14] / proj[10] - var zf: float = proj[14] / (proj[10] + 1.0) - camera.fov = fov_deg - camera.near = zn - camera.far = zf - - var vp_offset := Vector2.ZERO - if draw.has("viewport"): - var vp: PackedFloat32Array = draw["viewport"] - var vp_x: float = vp[0] - var vp_y: float = vp[1] - var vp_w: float = vp[2] - var vp_h: float = vp[3] - var vp_size := get_viewport().get_visible_rect().size - if vp_w > 0.0 and vp_h > 0.0 and (vp_w < vp_size.x or vp_h < vp_size.y or vp_x > 0.0 or vp_y > 0.0): - var offset_px_x: float = (vp_x + vp_w / 2.0) - (vp_size.x / 2.0) - var offset_px_y: float = (vp_y + vp_h / 2.0) - (vp_size.y / 2.0) - if absf(offset_px_x) > 0.5 or absf(offset_px_y) > 0.5: - var v_size_near := 2.0 * zn * tan(deg_to_rad(fov_deg) / 2.0) - var h_size_near := v_size_near * (vp_size.x / vp_size.y) - var off_near_x := (offset_px_x / vp_size.x) * h_size_near - var off_near_y := -(offset_px_y / vp_size.y) * v_size_near - vp_offset = Vector2(-off_near_x, off_near_y) - - var v_size_near := 2.0 * zn * tan(deg_to_rad(fov_deg) / 2.0) - if vp_offset != Vector2.ZERO: - camera.set_frustum(v_size_near, vp_offset, zn, zf) - else: - camera.set_perspective(fov_deg, zn, zf) - -func _apply_light(draw: Dictionary, view: PackedFloat32Array) -> void: - var view_basis := d3d_transform(view).basis - var direction: Vector3 = view_basis * draw["light0_direction"] - if direction.length_squared() < 1e-8: - light.visible = false - return - light.visible = true - # DirectionalLight3D shines along its -Z. - light.transform = Transform3D(Basis.looking_at(direction.normalized(), - Vector3.UP if absf(direction.normalized().y) < 0.99 else Vector3.RIGHT), Vector3.ZERO) - var diffuse: Color = draw["light0_diffuse"] - light.light_color = Color(diffuse.r, diffuse.g, diffuse.b) - light.light_energy = 1.0 - # D3D lighting: ambient = D3DRS_AMBIENT + light ambient, both times the material ambient. - var ambient: Color = draw["ambient"] - var light_ambient: Color = draw["light0_ambient"] - var active_environment: Environment = environment.environment - if terrain: - var map_environment := terrain.get_node_or_null("WorldEnv") as WorldEnvironment - if map_environment: - active_environment = map_environment.environment - if active_environment: - active_environment.ambient_light_color = Color(minf(ambient.r + light_ambient.r, 1.0), - minf(ambient.g + light_ambient.g, 1.0), minf(ambient.b + light_ambient.b, 1.0)) - -func _mesh_instance(index: int) -> MeshInstance3D: - while _meshes.size() <= index: - var instance := MeshInstance3D.new() - instance.name = "Draw%d" % _meshes.size() - instance.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF - instance.layers = 1 << 1 - add_child(instance) - _meshes.append(instance) - return _meshes[index] - -func _mesh_for_draw(draw: Dictionary) -> ArrayMesh: - var key: int = draw.get("geometry_key", 0) - if key == 0: - return _build_mesh(draw) - var revision: int = draw.get("geometry_revision", 0) - var cached: Array = _geometry_cache.get(key, []) - if not cached.is_empty() and int(cached[0]) == revision: - cached[2] = _geometry_frame - return cached[1] - var mesh := _build_mesh(draw) - _geometry_cache[key] = [revision, mesh, _geometry_frame] - return mesh - -func _build_mesh(draw: Dictionary) -> ArrayMesh: - var arrays := [] - arrays.resize(Mesh.ARRAY_MAX) - arrays[Mesh.ARRAY_VERTEX] = draw["positions"] - if draw.has("normals"): - arrays[Mesh.ARRAY_NORMAL] = draw["normals"] - if draw.has("uv0"): - arrays[Mesh.ARRAY_TEX_UV] = draw["uv0"] - if draw.has("diffuse"): - arrays[Mesh.ARRAY_COLOR] = draw["diffuse"] - arrays[Mesh.ARRAY_INDEX] = draw["indices"] - var mesh := ArrayMesh.new() - mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays) - return mesh - -func _material(draw: Dictionary) -> StandardMaterial3D: - var lit: bool = draw["lighting"] - var z_write: int = draw.get("z_write", 1) - var tf: int = int(draw.get("texture_factor", 0xFFFFFFFF)) - var tf_a: float = float((tf >> 24) & 0xFF) / 255.0 - var tf_r: float = float((tf >> 16) & 0xFF) / 255.0 - var tf_g: float = float((tf >> 8) & 0xFF) / 255.0 - var tf_b: float = float(tf & 0xFF) / 255.0 - var factor_color := Color(tf_r, tf_g, tf_b, tf_a) - - var uses_tf: bool = bool(draw.get("uses_tf", false)) - var has_tf: bool = uses_tf and (tf != 0xFFFFFFFF and tf != -1) - var tf_key := "%02x%02x%02x%02x" % [int(tf_r * 63.0), int(tf_g * 63.0), int(tf_b * 63.0), int(tf_a * 63.0)] if has_tf else "" - var has_diffuse: bool = draw.has("diffuse") - var is_alpha: bool = draw["alpha_blend"] or (has_tf and factor_color.a < 0.99) - # alpha_blend / alpha_test / lit are bools: %d needs them as ints. - var key := "%s|%d|%d|%d|%d|%d|%d|%d|%d|%d|%s|%s|%s" % [draw["texture0"], int(draw["alpha_blend"]), draw["src_blend"], - draw["dest_blend"], int(draw["alpha_test"]), draw["alpha_ref"], draw["cull_mode"], int(lit), - int(has_diffuse), z_write, draw["material_diffuse"], draw["material_emissive"], tf_key] - if _materials.has(key): - return _materials[key] - var material := StandardMaterial3D.new() - var texture_name: String = draw["texture0"] - if not texture_name.is_empty(): - # The ported client reads its models from 40250's packs; the texture must come from there too. - material.albedo_texture = UiAssets.load_pack_tex(texture_name) - - var base_diffuse: Color = draw["material_diffuse"] if lit else Color.WHITE - if has_tf: - material.albedo_color = Color(base_diffuse.r * factor_color.r, base_diffuse.g * factor_color.g, - base_diffuse.b * factor_color.b, base_diffuse.a * factor_color.a) - else: - material.albedo_color = base_diffuse - - material.emission_enabled = lit and draw["material_emissive"] != Color(0, 0, 0, 0) - if material.emission_enabled: - material.emission = draw["material_emissive"] - # D3D adds the emissive term to the lit vertex colour, and D3DTOP_MODULATE then multiplies that by the - # texture; Godot adds emission after the texture, so a black additive glow (click_glow_select copy.jpg) - # came out as a grey quad. - if material.albedo_texture: - material.emission_texture = material.albedo_texture - material.emission_operator = BaseMaterial3D.EMISSION_OP_MULTIPLY - material.shading_mode = BaseMaterial3D.SHADING_MODE_PER_VERTEX if lit \ - else BaseMaterial3D.SHADING_MODE_UNSHADED - material.specular_mode = BaseMaterial3D.SPECULAR_DISABLED - material.roughness = 1.0 - material.vertex_color_use_as_albedo = has_diffuse - match int(draw["cull_mode"]): - D3DCULL_NONE: - material.cull_mode = BaseMaterial3D.CULL_DISABLED - D3DCULL_CW: - material.cull_mode = BaseMaterial3D.CULL_BACK - D3DCULL_CCW: - material.cull_mode = BaseMaterial3D.CULL_FRONT - _: - material.cull_mode = BaseMaterial3D.CULL_BACK - if z_write == 0: - material.depth_draw_mode = BaseMaterial3D.DEPTH_DRAW_DISABLED - elif is_alpha: - material.depth_draw_mode = BaseMaterial3D.DEPTH_DRAW_ALWAYS - else: - material.depth_draw_mode = BaseMaterial3D.DEPTH_DRAW_OPAQUE_ONLY - if is_alpha: - material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA - if int(draw["dest_blend"]) == D3DBLEND_ONE: - material.blend_mode = BaseMaterial3D.BLEND_MODE_ADD - else: - material.blend_mode = BaseMaterial3D.BLEND_MODE_MIX - elif draw["alpha_test"]: - material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA_SCISSOR - material.alpha_scissor_threshold = float(draw["alpha_ref"]) / 255.0 - _materials[key] = material - return material diff --git a/project/python_3d_surface.gd.uid b/project/python_3d_surface.gd.uid deleted file mode 100644 index 52f974ef..00000000 --- a/project/python_3d_surface.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://07xdrb10ur7c diff --git a/project/python_game_render_test.gd b/project/python_game_render_test.gd deleted file mode 100644 index 7dffcdb2..00000000 --- a/project/python_game_render_test.gd +++ /dev/null @@ -1,510 +0,0 @@ -# python_game_render_test — 2V2-e:Godot 里跑 40250 的 system.py,按 port_login_flow_test 的同一条路径 -# 登录 → 选人 → 进游戏,把 RenderGame 的绘制命令(python_3d_surface.gd)画出来并截图。 -# -# godot --path project --script res://python_game_render_test.gd -# MT_FAKE_MOB_COUNT=24 MT_PROFILE_FRAME=1 MT_PROFILE_COMBAT=1 script/python_game_render_test.sh -# -# 服务器与账号只从环境变量读(MT_LIVE_HOST / MT_LIVE_LOGIN / MT_LIVE_PASSWORD,可选 MT_LIVE_AUTH_PORT -# 11000、MT_LIVE_GAME_PORT 13000、MT_LIVE_SLOT 0);Python 侧也用 os.environ 取,不进源码文本。 -# 离线由 script/python_game_render_test.sh 起 port_fake_login_server 并把这些变量指向它。 -# 截图与 report.json 写到 MT_RENDER_OUTPUT(默认 build/rendering/python-game-<时间>)。 -extends SceneTree - -const PythonUISurface = preload("res://python_ui_surface.gd") -const Python3DSurface = preload("res://python_3d_surface.gd") - -var failed := false -var ui: Control -var world: Node3D - -func _initialize() -> void: - call_deferred("run") - -func _check(ok: bool, message: String) -> void: - if ok: - print("ok: " + message) - else: - failed = true - printerr("FAIL: " + message) - -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 _finish(output: String, report: Dictionary) -> void: - report["passed"] = not failed - var file := FileAccess.open(output.path_join("report.json"), FileAccess.WRITE) - if file: - file.store_string(JSON.stringify(report, " ")) - file.close() - print("python_game_render_test: %s (%s)" % ["FAIL" if failed else "PASS", output]) - ui.queue_free() - await process_frame - quit(1 if failed else 0) - -func run() -> void: - root.size = Vector2i(1024, 768) - var output := OS.get_environment("MT_RENDER_OUTPUT") - if output.is_empty(): - output = ProjectSettings.globalize_path("res://../build/rendering/python-game-%d" % Time.get_unix_time_from_system()) - elif not output.is_absolute_path(): - output = ProjectSettings.globalize_path("res://../" + output) - DirAccess.make_dir_recursive_absolute(output) - var report := {"output": output, "headless": DisplayServer.get_name() == "headless"} - if OS.has_environment("MT_FAKE_MOB_COUNT"): - report["fake_mob_count"] = int(OS.get_environment("MT_FAKE_MOB_COUNT")) - - var host := OS.get_environment("MT_LIVE_HOST") - if host.is_empty() or OS.get_environment("MT_LIVE_LOGIN").is_empty() or OS.get_environment("MT_LIVE_PASSWORD").is_empty(): - printerr("python_game_render_test: needs MT_LIVE_HOST, MT_LIVE_LOGIN, MT_LIVE_PASSWORD") - quit(2) - return - - world = Python3DSurface.new() - world.name = "Python3DSurface" - ui = PythonUISurface.new() - ui.name = "PythonUISurface" - # The UI surface runs CPythonApplication::Process in its _process; the 3D surface reads the draws - # of that frame after it, so it comes second in the tree. - root.add_child(ui) - root.add_child(world) - var err: String = ui.run_app() - _check(err == "", "system.py reached app.Loop() (%s)" % err) - if err != "": - await _finish(output, report) - return - - _check(_py("import __main__, os, networkModule, introLogin, introSelect, introLoading, game\n" - + "__main__._stream = [o for o in __import__('gc').get_objects() if isinstance(o, networkModule.MainStream)][0]"), - "found networkModule.MainStream") - var at_login := await _pump_until(20, func(): return _py( - "assert isinstance(_stream.curPhaseWindow, introLogin.LoginWindow)")) - _check(at_login, "login window") - - # The account from the environment is typed into the login board as key events (WM_KEYDOWN/WM_CHAR - # through PythonUISurface → CPythonIME); Return runs __OnClickLoginButton → LoginWindow.Connect. - _check(_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()"), "login board open") - _type(OS.get_environment("MT_LIVE_LOGIN")) - _press(KEY_TAB) - _type(OS.get_environment("MT_LIVE_PASSWORD")) - _check(_py("assert _w.idEditLine.GetText() == _env['MT_LIVE_LOGIN']\n" - + "assert _w.pwdEditLine.GetText() == _env['MT_LIVE_PASSWORD']"), "account typed through the IME") - _check(_py("del _env, _w"), "login locals dropped") - _press(KEY_ENTER) - var selecting := await _pump_until(30, func(): return _py( - "assert isinstance(_stream.curPhaseWindow, introSelect.SelectCharacterWindow)")) - _check(selecting, "character select") - if selecting: - _check(_py("_stream.curPhaseWindow.SelectSlot(_slot)\n_stream.curPhaseWindow.StartGame()"), "StartGame") - var in_game := selecting and await _pump_until(60, func(): return _py( - "assert isinstance(_stream.curPhaseWindow, game.GameWindow) and _stream.curPhaseWindow.IsShow()")) - _check(in_game, "game window") - - # The main character's draws reach the 3D surface. - var drawn := in_game and await _pump_until(10, func(): return world.draw_count > 0) - _check(drawn, "RenderGame draws shown (%d)" % world.draw_count) - # Let the WAIT motion run a little before the picture, and let GamePhase (3 packets/frame) - # finish draining the entergame burst + GC_PING when MT_FAKE_MOB_COUNT is large. - var warmup_frames := maxi(30, int(report.get("fake_mob_count", 1)) + 20) - for i in warmup_frames: - await process_frame - if OS.has_environment("MT_PROFILE_FRAME"): - var frame_ms: Array[float] = [] - var ui_update_ms: Array[float] = [] - var ui_draw_ms: Array[float] = [] - var world_update_ms: Array[float] = [] - var previous := Time.get_ticks_usec() - for i in 240: - await process_frame - var current := Time.get_ticks_usec() - frame_ms.append(float(current - previous) / 1000.0) - ui_update_ms.append(ui.profile_ui_update_ms) - ui_draw_ms.append(ui.profile_draw_ms) - world_update_ms.append(world.profile_update_ms) - previous = current - frame_ms.sort() - ui_update_ms.sort() - ui_draw_ms.sort() - world_update_ms.sort() - var total := 0.0 - for sample in frame_ms: - total += sample - report["frame_profile"] = { - "samples": frame_ms.size(), - "mean_ms": total / frame_ms.size(), - "p50_ms": frame_ms[119], - "p95_ms": frame_ms[227], - "ui_update_p50_ms": ui_update_ms[119], - "ui_draw_p50_ms": ui_draw_ms[119], - "world_update_p50_ms": world_update_ms[119], - } - var command_kinds := {} - var image_texts := {} - for command in Metin2PythonHost.ui_render_commands(): - var kind: String = str(command.get("kind", "unknown")) - command_kinds[kind] = int(command_kinds.get(kind, 0)) + 1 - if kind == "image": - var image_name: String = str(command.get("text", "")) - image_texts[image_name] = int(image_texts.get(image_name, 0)) + 1 - report["frame_profile"]["ui_commands"] = command_kinds - report["frame_profile"]["image_texts"] = image_texts - var batched_commands := 0 - var glyph_batches := 0 - var batched_glyphs := 0 - for command in Metin2PythonHost.ui_render_commands_batched(): - batched_commands += 1 - if command.get("kind", "") == "glyph_batch": - glyph_batches += 1 - batched_glyphs += int(command.get("glyph_count", 0)) - report["frame_profile"]["batched_commands"] = batched_commands - report["frame_profile"]["glyph_batches"] = glyph_batches - report["frame_profile"]["batched_glyphs"] = batched_glyphs - print("FRAME_PROFILE ", report["frame_profile"]) - report["draw_count"] = world.draw_count - report["unlit_stand_in_count"] = world.unlit_stand_in_count - report["terrain"] = world.terrain_report - report["atlas_entries"] = world._atlas.size() - _check(not Metin2PythonHost.current_map_name().is_empty(), "native AtlasInfo chooses the current map") - if world.terrain: - report["terrain_base_cm"] = str(world.terrain.call("get_map_base_cm")) - report["native_map_name"] = Metin2PythonHost.current_map_name() - _check(world.map_path == Metin2PythonHost.current_map_name(), "terrain uses native Warp map") - var first_draws: Array = Metin2PythonHost.render3d_draws() - if not first_draws.is_empty(): - var camera_world: Vector3 = world.d3d_transform(first_draws[0]["view"]).affine_inverse().origin - report["camera_world_cm"] = str(camera_world) - report["terrain_height_at_camera_cm"] = float(world.terrain.call("sample_height", - camera_world.x / 100.0, -camera_world.y / 100.0)) * 100.0 - var tile_bounds := [] - for tile in world.terrain.find_children("Terrain*", "MeshInstance3D", true, false): - if tile_bounds.size() >= 4: - break - tile_bounds.append(str(tile.global_transform * tile.mesh.get_aabb())) - report["terrain_camera_bounds"] = tile_bounds - _check(world.terrain != null and int(world.terrain_report.get("chunks_built", 0)) > 0, - "40250 terrain height tiles loaded") - _check(int(world.terrain_report.get("chunks_splatted", 0)) > 0, - "40250 terrain textures applied") - _check(bool(world.terrain_report.get("env_ok", false)) and world.light.visible, - "map .msenv lights the character") - _check(world.unlit_stand_in_count == 0, "unlit stand-in removed") - report["camera_fov"] = world.camera.fov - report["camera_near"] = world.camera.near - report["camera_far"] = world.camera.far - var textures := [] - var draws: Array = Metin2PythonHost.render3d_draws() - var native_lit_draws := 0 - var view_finite := true - # Identify character, monster and NPC draws (excluding map objects, buildings, trees and roofs). - var actor_draws: Array = [] - for draw in draws: - var tex: String = String(draw.get("texture0", "")).to_lower().replace("\\", "/") - for k in ["pc/", "pc2/", "monster/", "npc/"]: - if k in tex: - actor_draws.append(draw) - break - - for draw in draws: - if draw["light0"]: - native_lit_draws += 1 - for value in draw["view"]: - if not is_finite(value): - view_finite = false - - var actor_foot_cm := INF - var foot_xy := Vector2.ZERO - for draw in actor_draws: - var world_xform: Transform3D = world.d3d_transform(draw["world"]) - for vertex in draw["positions"]: - var p: Vector3 = world_xform * vertex - if p.z < actor_foot_cm: - actor_foot_cm = p.z - foot_xy = Vector2(p.x, -p.y) - # Every actor, not only the main one, stands on the map. An actor's parts (body, head, hair, teeth) - # share its world matrix; the lowest vertex over those parts meets sample_height. - var actor_low := {} - for draw in actor_draws: - var draw_xform: Transform3D = world.d3d_transform(draw["world"]) - var key := str(draw["world"]) - for vertex in draw["positions"]: - var p: Vector3 = draw_xform * vertex - if not actor_low.has(key) or p.z < actor_low[key].z: - actor_low[key] = p - if world.terrain and not actor_low.is_empty(): - var worst_actor_gap_cm := 0.0 - for low in actor_low.values(): - var ground := float(world.terrain.call("sample_height", low.x / 100.0, -low.y / 100.0)) * 100.0 - worst_actor_gap_cm = maxf(worst_actor_gap_cm, absf(low.z - ground)) - report["actors"] = actor_low.size() - report["worst_actor_gap_cm"] = worst_actor_gap_cm - _check(worst_actor_gap_cm < 50.0, "every actor stands on the map (%d actors, worst gap %.1f cm)" - % [actor_low.size(), worst_actor_gap_cm]) - report["native_lit_draws"] = native_lit_draws - report["view_finite"] = view_finite - _check(view_finite, "native camera view stays finite") - if world.terrain and not is_inf(actor_foot_cm): - var ground_cm := float(world.terrain.call("sample_height", foot_xy.x / 100.0, foot_xy.y / 100.0)) * 100.0 - report["actor_foot_cm"] = actor_foot_cm - report["ground_at_foot_cm"] = ground_cm - _check(absf(actor_foot_cm - ground_cm) < 120.0, - "native actor feet meet map height (delta %.1f cm)" % absf(actor_foot_cm - ground_cm)) - _check(not drawn or native_lit_draws == draws.size(), - "native RenderGame light0 comes from map .msenv (%d/%d)" % [native_lit_draws, draws.size()]) - if not draws.is_empty(): - report["proj"] = Array(draws[0]["proj"]) - report["view"] = Array(draws[0]["view"]) - # Each draw's render state (everything but the arrays and matrices). - var states := [] - for draw in draws: - var state := {} - for key in draw: - if typeof(draw[key]) in [TYPE_BOOL, TYPE_INT, TYPE_FLOAT, TYPE_STRING, TYPE_COLOR, TYPE_VECTOR3]: - state[key] = str(draw[key]) - state["vertices"] = draw["positions"].size() - if draw.has("uv0"): - var lo := Vector2(INF, INF) - var hi := -lo - for uv in draw["uv0"]: - lo = lo.min(uv) - hi = hi.max(uv) - state["uv0_range"] = str([lo, hi]) - state["indices"] = draw["indices"].size() - states.append(state) - report["draw_states"] = states - for draw in draws: - if not draw["texture0"].is_empty() and not textures.has(draw["texture0"]): - textures.append(draw["texture0"]) - report["textures"] = textures - var surfaces_textured := 0 - var lit_surfaces := 0 - var distinct_textures := {} - for instance in world.find_children("Draw*", "MeshInstance3D", false, false): - if instance.visible and instance.mesh: - var material: StandardMaterial3D = instance.get_active_material(0) - if material and material.albedo_texture: - surfaces_textured += 1 - distinct_textures[material.albedo_texture] = true - if material and material.shading_mode == BaseMaterial3D.SHADING_MODE_PER_VERTEX: - lit_surfaces += 1 - report["surfaces_textured"] = surfaces_textured - report["lit_surfaces"] = lit_surfaces - report["distinct_textures"] = distinct_textures.size() - # Every texture name gets its own texture (face, body, hair are not one material). - _check(distinct_textures.size() == textures.size(), - "one texture per texture name (%d of %d)" % [distinct_textures.size(), textures.size()]) - # CPythonMiniMap renders the outdoor map's minimap.dds tiles through minimap_image_filter.dds; - # the orthographic draws reach the UI stream as masked images. - var minimap_tiles := 0 - for command in Metin2PythonHost.ui_render_commands(): - if command.has("mask") and String(command["text"]).ends_with("minimap.dds"): - minimap_tiles += 1 - report["minimap_tiles"] = minimap_tiles - _check(minimap_tiles > 0, "minimap tiles drawn through the filter mask (%d)" % minimap_tiles) - var bounds := [] - for instance in world.find_children("Draw*", "MeshInstance3D", false, false): - if instance.visible and instance.mesh: - bounds.append(str(instance.transform * instance.mesh.get_aabb())) - report["camera_space_bounds"] = bounds - _check(not drawn or surfaces_textured > 0, "character surfaces have their diffuse textures (%d)" % surfaces_textured) - _check(lit_surfaces > 0, "character surfaces use directional lighting (%d)" % lit_surfaces) - if DisplayServer.get_name() != "headless": - await RenderingServer.frame_post_draw - var image := root.get_texture().get_image() - _check(image.save_png(output.path_join("game.png")) == OK, "screenshot saved") - # The character is centred by __UpdateCamera: the middle of the picture is not background. - var centre := Rect2i(image.get_width() * 2 / 5, image.get_height() * 3 / 10, - image.get_width() / 5, image.get_height() * 2 / 5) - var lit := 0 - for y in range(centre.position.y, centre.end.y, 4): - for x in range(centre.position.x, centre.end.x, 4): - if image.get_pixel(x, y).get_luminance() > 0.05: - lit += 1 - report["centre_lit_samples"] = lit - _check(lit > 50, "character pixels in the middle of the screen (%d samples)" % lit) - # The 3D pass alone, for looking at the character without the UI. - ui.visible = false - await RenderingServer.frame_post_draw - var game_image := root.get_texture().get_image() - game_image.save_png(output.path_join("game_3d.png")) - var textured_pairs := 0 - for y in range(game_image.get_height() * 3 / 4, game_image.get_height() - 20, 13): - for x in range(30, game_image.get_width() - 35, 17): - var left := game_image.get_pixel(x, y).get_luminance() - var right := game_image.get_pixel(x + 5, y).get_luminance() - if absf(left - right) > 0.04: - textured_pairs += 1 - report["ground_texture_pairs"] = textured_pairs - _check(textured_pairs > 20, "textured ground visible in screenshot (%d local contrasts)" % textured_pairs) - # The camera looks 27 degrees down, so the map behind the actor fills the top of the picture - # too (legacy metre-unit fog once washed it out to flat sky colour). - var far_pairs := 0 - for y in range(game_image.get_height() / 10, game_image.get_height() * 3 / 10, 13): - for x in range(30, game_image.get_width() - 35, 17): - var left := game_image.get_pixel(x, y).get_luminance() - var right := game_image.get_pixel(x + 5, y).get_luminance() - if absf(left - right) > 0.04: - far_pairs += 1 - report["far_ground_texture_pairs"] = far_pairs - _check(far_pairs > 20, "map behind the actor visible (%d local contrasts)" % far_pairs) - ui.visible = true - - # Combat (批次 4-b) with Godot mouse events: hold the left button on the fake server's stray dog; the port's - # CPythonPlayer walks out of the village's safe zone and attacks, the fake server's GC_TARGET updates reach - # uiTarget.TargetBoard.SetHP (wrapped here to record them) and GC_DEAD comes with the 0 % update. - if in_game: - var dog_vid := 0x23456 - _check(_py("import uiTarget\n" - + "_target_hp = []\n" - + "_set_hp = uiTarget.TargetBoard.SetHP\n" - + "def _record_hp(board, hp):\n" - + "\t_target_hp.append(hp)\n" - + "\t_set_hp(board, hp)\n" - + "uiTarget.TargetBoard.SetHP = _record_hp"), "target HP recorder installed") - var dog_screen := func() -> Vector2: - var parts := Metin2PythonHost.evaluate("'%%d,%%d,%%d' %% __import__('chr').GetPixelPosition(%d)" % dog_vid).split(",") - if parts.size() != 3: - return Vector2(-1, -1) - var world_pos := Vector3(float(parts[0]), -float(parts[1]), float(parts[2]) + 40.0) - for draw in Metin2PythonHost.render3d_draws(): - if draw["proj"][11] == -1.0: - return world.camera.unproject_position(world.d3d_transform(draw["view"]) * world_pos) - return Vector2(-1, -1) - var move := func(position: Vector2) -> void: - var event := InputEventMouseMotion.new() - event.position = position - event.global_position = position - root.push_input(event) - var button := func(pressed: bool, position: Vector2) -> void: - var event := InputEventMouseButton.new() - event.button_index = MOUSE_BUTTON_LEFT - event.pressed = pressed - event.position = position - event.global_position = position - root.push_input(event) - # The actor may still be walking from the earlier step, and the camera follows it: wait until the dog - # stands still on screen before every click. - var settled := func() -> Vector2: - var last := Vector2(-100, -100) - var now := Vector2.ZERO - for frame in 600: - await process_frame - now = dog_screen.call() - if now.x >= 0.0 and now.distance_to(last) < 0.5: - break - last = now - return now - var at: Vector2 = await settled.call() - # The earlier walk does not end on the same spot every run, and the dog can land under the minimap or the - # taskbar: walk part of the way first (a ground click, which __OnClickGround turns into a move). - var clear := Rect2(40, 150, 780, 520) - for attempt in 3: - if clear.has_point(at): - break - var toward: Vector2 = clear.get_center().lerp(at, 0.5).clamp(clear.position, clear.end) - move.call(toward) - await process_frame - button.call(true, toward) - await process_frame - button.call(false, toward) - at = await settled.call() - report["dog_screen"] = str(at) - _check(at.x >= 0.0, "stray dog projected on screen (%s)" % str(at)) - move.call(at) - await process_frame - button.call(true, at) - var mid_shot := false - var combat_condition := func(): - var now: Vector2 = dog_screen.call() - if now.x >= 0.0: - at = now - move.call(at) - if not mid_shot and Metin2PythonHost.evaluate("33 in _target_hp") == "True" and DisplayServer.get_name() != "headless" and not OS.has_environment("MT_PROFILE_COMBAT"): - mid_shot = true - root.get_texture().get_image().save_png(output.path_join("combat_mid.png")) - return Metin2PythonHost.evaluate("_target_hp[-1:] == [0]") == "True" - var dead := false - if OS.has_environment("MT_PROFILE_COMBAT"): - var combat_frames: Array[float] = [] - var combat_ui_update: Array[float] = [] - var combat_ui_draw: Array[float] = [] - var combat_world_update: Array[float] = [] - var combat_draw_counts: Array[int] = [] - var deadline := Time.get_ticks_msec() + 20000 - var previous := Time.get_ticks_usec() - while Time.get_ticks_msec() < deadline: - if combat_condition.call(): - dead = true - break - await process_frame - var current := Time.get_ticks_usec() - combat_frames.append(float(current - previous) / 1000.0) - combat_ui_update.append(ui.profile_ui_update_ms) - combat_ui_draw.append(ui.profile_draw_ms) - combat_world_update.append(world.profile_update_ms) - combat_draw_counts.append(world.draw_count) - previous = current - combat_frames.sort() - combat_ui_update.sort() - combat_ui_draw.sort() - combat_world_update.sort() - combat_draw_counts.sort() - if not combat_frames.is_empty(): - var n := combat_frames.size() - report["combat_profile"] = { - "samples": n, - "frame_p50_ms": combat_frames[n / 2], - "frame_p95_ms": combat_frames[mini(n - 1, int(n * 0.95))], - "frame_max_ms": combat_frames[n - 1], - "ui_update_p50_ms": combat_ui_update[n / 2], - "ui_update_p95_ms": combat_ui_update[mini(n - 1, int(n * 0.95))], - "ui_draw_p50_ms": combat_ui_draw[n / 2], - "ui_draw_p95_ms": combat_ui_draw[mini(n - 1, int(n * 0.95))], - "world_update_p50_ms": combat_world_update[n / 2], - "world_update_p95_ms": combat_world_update[mini(n - 1, int(n * 0.95))], - "draw_count_p50": combat_draw_counts[n / 2], - "draw_count_max": combat_draw_counts[n - 1], - } - print("COMBAT_PROFILE ", report["combat_profile"]) - else: - dead = await _pump_until(20, combat_condition) - button.call(false, at) - if DisplayServer.get_name() != "headless": - root.get_texture().get_image().save_png(output.path_join("combat.png")) - report["target_hp"] = Metin2PythonHost.evaluate("_target_hp") - _check(dead, "stray dog killed with the held left button (target HP %s)" % report["target_hp"]) - _check(_py("uiTarget.TargetBoard.SetHP = _set_hp\ndel _set_hp, _record_hp, _target_hp"), "target HP recorder removed") - await _finish(output, report) diff --git a/project/python_game_render_test.gd.uid b/project/python_game_render_test.gd.uid deleted file mode 100644 index 5f0c8cd2..00000000 --- a/project/python_game_render_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://g37mwhmliwc5 diff --git a/project/python_host_check.gd b/project/python_host_check.gd deleted file mode 100644 index 10b0dc46..00000000 --- a/project/python_host_check.gd +++ /dev/null @@ -1,233 +0,0 @@ -# python_host_check —— 在 app 进程里把内嵌解释器起来并跑 system.py(docs/PORT-PLAN.md 批次 2P step 4)。 -# -# 和 ctest 的 `port.python_launcher` 跑的是同一条序列,区别只有一个:那边是独立可执行文件,这边在 -# Godot 进程里,由 GDScript 发起 —— step 4 要验的就是这个差别。2V0 把 C++ 模块注册上之前, -# 2V0-e 的玩法桩让 system.py 真正走到 prototype.RunApp(); -# 没设画布尺寸时 app.Create 没有设备,RunApp 捕获 CREATE_DEVICE 并给出原版提示;设了尺寸以后 -# system.py 停在 app.Loop() 里,由 ui_update 逐帧推进(_run_app_loop)。 -# -# 真机上通常没有 40250 的 Client/pack(APK 里只有 python27.zip),所以分两段:解释器 + 标准库这一段 -# 不需要 pack,任何平台都跑;system.py 这一段需要 pack,没有就跳过并说清楚。 -# -# 桌面:python_host_test.gd(--script);真机:MT_TEST_MODE=python 从 client_main.gd 进来。 -extends RefCounted - -const PythonUISurface = preload("res://python_ui_surface.gd") - -const EXPECTED_STOP := "Sorry, Your system does not support 3D graphics" - -var fail := 0 - -func _ck(ok: bool, message: String) -> void: - if not ok: - fail += 1 - printerr("FAIL: " + message) - else: - print("ok: " + message) - -# 返回失败数;0 表示落在预期的停止点上(或按上面的规则跳过)。 -func run() -> int: - if not ClassDB.class_exists("Metin2PythonHost"): - printerr("FAIL: Metin2PythonHost extension is not registered") - return 1 - if not FileAccess.file_exists("res://python27.zip"): - print("SKIP: python_host_check(没有 res://python27.zip,需要 -DMTGODOT_EMBED_PYTHON=ON 的构建)") - return 0 - - # 40250 Main() 先注册 pack,再造 CPythonLauncher —— system.py 是从 pack 里读出来的。 - var has_pack := AssetRoot.pack_ready() - - var start_error: String = Metin2PythonHost.start("") - _ck(start_error == "", "解释器在 app 进程里起来了:" + start_error) - if start_error != "": - return fail - _ck(Metin2PythonHost.is_running(), "is_running() 为真") - _ck(Metin2PythonHost.start("") == "", "重复 start() 是空操作") - - # 标准库真的在 sys.path 上(真机是沙盒里那份 python27.zip),codec 注册表也重建过。 - _ck(Metin2PythonHost.run_line("import os, string, types, codecs") == "", "标准库可 import") - _ck(Metin2PythonHost.run_line("assert u'\\xe4'.encode('cp1252') == '\\xe4'") == "", "codec 注册表可用") - _ck(Metin2PythonHost.run_line("import sys; assert sys.path") == "", "sys.path 非空") - - if has_pack: - # RunMainScript:initpack() + __DEBUG__ + __COMMAND_LINE__ + RunFile("system.py")。 - var err: String = Metin2PythonHost.run_main_script("") - print("system.py -> %s" % err) - _ck(err.contains(EXPECTED_STOP), - "system.py 进入 prototype.RunApp(),Create() 报告待实现的图形设备") - _ck(Metin2PythonHost.run_line("import player, item; assert not hasattr(player, '__port_stub__') and not hasattr(item, '__port_stub__')") == "", - "player 与 item 为 40250 原生移植模块") - _ck(Metin2PythonHost.run_line("import net; assert not hasattr(net, '__port_stub__') and net.PHASE_WINDOW_SELECT == 2 and not net.IsConnect()") == "", - "net 是移植的 40250 PythonNetworkStreamModule(2V1)") - # 真正从 Godot 入口转发事件给 40250 的 CWindowManager,再由窗口树回调 Python。 - Metin2PythonHost.set_ui_size(800, 600) - var probe_source := """ -import wndMgr -_ui_events = [] -class _UIProbe: - def OnMouseOverIn(self): _ui_events.append('in') - def OnMouseOverOut(self): _ui_events.append('out') - def OnMouseOver(self): pass - def OnMouseLeftButtonDown(self): _ui_events.append('down') - def OnMouseLeftButtonUp(self): _ui_events.append('up'); return 1 - def OnSetFocus(self): _ui_events.append('focus') - def OnKillFocus(self): _ui_events.append('blur') - def OnUpdate(self): _ui_events.append('update') - def OnKeyDown(self, key): _ui_events.append('key:%d' % key); return 1 - def OnKeyUp(self, key): return 1 -_ui_probe = _UIProbe() -_ui_window = wndMgr.Register(_ui_probe, 'UI') -wndMgr.SetWindowSize(_ui_window, 100, 50) -wndMgr.SetWindowPosition(_ui_window, 10, 20) -wndMgr.Show(_ui_window) -wndMgr.SetFocus(_ui_window) -""" - _ck(Metin2PythonHost.run_line(probe_source) == "", "40250 窗口测试对象已在 Godot 进程中创建") - Metin2PythonHost.ui_mouse_move(15, 25) - Metin2PythonHost.ui_mouse_button(1, true, 15, 25) - Metin2PythonHost.ui_mouse_button(1, false, 15, 25) - Metin2PythonHost.ui_key(42, true) - Metin2PythonHost.ui_update() - _ck(Metin2PythonHost.run_line("assert 'in' in _ui_events and 'down' in _ui_events and 'up' in _ui_events and 'focus' in _ui_events and 'key:42' in _ui_events and 'update' in _ui_events") == "", - "Godot 输入、焦点和更新事件抵达 40250 Python 窗口") - _ck(Metin2PythonHost.run_line("_ui_bar = wndMgr.RegisterBar(_UIProbe(), 'UI'); wndMgr.SetWindowSize(_ui_bar, 20, 10); wndMgr.SetWindowPosition(_ui_bar, 30, 40); wndMgr.SetColor(_ui_bar, 0x7f336699); wndMgr.Show(_ui_bar)") == "", - "40250 Bar 窗口已创建") - var draws: Array = Metin2PythonHost.ui_render_commands() - var has_bar := false - for draw in draws: - if draw["kind"] == "bar" and draw["x1"] == 30.0 and draw["y1"] == 40.0 and draw["x2"] == 50.0 and draw["y2"] == 50.0 and draw["argb"] == 0x7f336699: - has_bar = true - _ck(has_bar, "40250 Bar 绘制调用进入 Godot Canvas 命令队列") - var clip_source := """ -import grp -class _ClipProbe(_UIProbe): - def OnRender(self): - _ui_events.append('renderclip') - grp.SetViewport(0.0, 0.0, 0.05, 0.08) -_ui_clip_probe = _ClipProbe() -_ui_clip = wndMgr.Register(_ui_clip_probe, 'UI') -wndMgr.SetWindowSize(_ui_clip, 800, 600) -wndMgr.SetParent(_ui_bar, _ui_clip) -wndMgr.Show(_ui_clip) -""" - _ck(Metin2PythonHost.run_line(clip_source) == "", "40250 裁剪窗口已创建") - draws = Metin2PythonHost.ui_render_commands() - _ck(Metin2PythonHost.run_line("assert 'renderclip' in _ui_events") == "", "父窗口 OnRender 已执行") - var has_clip := false - for draw in draws: - if draw["kind"] == "bar" and draw["clip_x2"] == 40.0 and draw["clip_y2"] == 48.0: - has_clip = true - _ck(has_clip, "40250 viewport 裁剪范围传给 Godot Canvas") - var surface := PythonUISurface.new() - var segment: PackedVector2Array = surface._clip_line(Vector2(-10, 10), Vector2(50, 10), Rect2(0, 0, 30, 30)) - _ck(segment.size() == 2 and segment[0] == Vector2(0, 10) and segment[1] == Vector2(30, 10), - "Godot Canvas 线段裁剪保留可见区间") - # ExpandedImage 的四边形(TL, TR, BL, BR)被 viewport 裁掉右半边,UV 随位置按仿射关系重算。 - var quad := PackedVector2Array([Vector2(0, 0), Vector2(20, 0), Vector2(0, 10), Vector2(20, 10)]) - var quad_uv := PackedVector2Array([Vector2(0, 0), Vector2(1, 0), Vector2(0, 1), Vector2(1, 1)]) - var clipped: Array = surface._clip_quad(quad, quad_uv, Rect2(0, 0, 10, 10)) - var max_u := 0.0 - for i in clipped[1].size(): - max_u = maxf(max_u, clipped[1][i].x) - if not is_equal_approx(clipped[1][i].x, clipped[0][i].x / 20.0): - max_u = -1.0 - _ck(clipped[0].size() >= 3 and is_equal_approx(max_u, 0.5), "ExpandedImage 四边形裁剪后 UV 同步收缩") - surface.free() - _ck(Metin2PythonHost.run_line("wndMgr.Destroy(_ui_bar)") == "", "Bar 窗口已销毁") - _ck(Metin2PythonHost.run_line("grp.RestoreViewport(); wndMgr.Destroy(_ui_clip)") == "", "裁剪窗口已销毁") - _ck(Metin2PythonHost.run_line("_ui_text_probe = _UIProbe(); _ui_text = wndMgr.RegisterTextLine(_ui_text_probe, 'UI'); wndMgr.SetWindowPosition(_ui_text, 70, 80); wndMgr.SetFontName(_ui_text, 'Tahoma:12'); wndMgr.SetText(_ui_text, 'Hello'); wndMgr.SetFontColor(_ui_text, 1.0, 0.5, 0.0); wndMgr.Show(_ui_text)") == "", - "40250 TextLine 已创建(和 ui.TextLine.__init__ 一样先 SetFontName)") - draws = Metin2PythonHost.ui_render_commands() - var glyphs: Array = draws.filter(func(d): return d["kind"] == "image" and String(d["text"]).begins_with("mem:")) - var min_x := INF - for draw in glyphs: - min_x = minf(min_x, draw["quad"][0].x) - _ck(glyphs.size() == 5 and absf(min_x - 70.0) <= 1.5, - "40250 TextLine 经 CGraphicFontTexture 字形页画成 5 个字形四边形(%d 个,左边 %.1f)" % [glyphs.size(), min_x]) - if not glyphs.is_empty(): - var page: Image = Metin2PythonHost.memory_texture(glyphs[0]["text"], -1) - var inked := false - if page != null: - for y in page.get_height(): - for x in page.get_width(): - if page.get_pixel(x, y).a > 0.0: - inked = true - break - if inked: - break - _ck(inked, "字形页(mem: 内存纹理)有字形像素") - var page_surface := preload("res://python_ui_surface.gd").new() - var page_texture: Texture2D = page_surface._texture(glyphs[0]["text"]) - _ck(page_texture != null and page_texture == page_surface._texture(glyphs[0]["text"]), - "Canvas 按 mem: 缓存字形页纹理") - page_surface.free() - _ck(Metin2PythonHost.run_line("wndMgr.Destroy(_ui_text)") == "", "TextLine 已销毁") - var image_path := "locale/en/ui/vkey/key_backspace.tga" - if Metin2Pack.exists(image_path): - var packed_texture: Texture2D = preload("res://ui/ui_assets.gd").load_tex(AssetRoot.path(), image_path) - _ck(packed_texture != null and packed_texture.get_width() > 0, - "40250 pack 图片可解码为 Godot 纹理") - var image_source := "_ui_image_probe = _UIProbe(); _ui_image = wndMgr.RegisterImageBox(_ui_image_probe, 'UI'); wndMgr.LoadImage(_ui_image, '%s'); wndMgr.SetWindowPosition(_ui_image, 120, 130); wndMgr.Show(_ui_image)" % image_path - _ck(Metin2PythonHost.run_line(image_source) == "", "40250 pack 图片已加载到 ImageBox") - draws = Metin2PythonHost.ui_render_commands() - var has_image := false - for draw in draws: - if draw["kind"] == "image" and String(draw["text"]).replace("\\", "/") == image_path and draw["x1"] == 120.0 and draw["y1"] == 130.0 and draw["x2"] > draw["x1"] and draw["y2"] > draw["y1"]: - has_image = true - _ck(has_image, "40250 ImageBox 图片进入 Godot Canvas 命令队列") - _ck(Metin2PythonHost.run_line("wndMgr.Destroy(_ui_image)") == "", "ImageBox 已销毁") - var sub_path := "d:/ymir work/ui/cursor/cursor.sub" - if Metin2Pack.exists(sub_path): - _ck(Metin2PythonHost.run_line("import grpImage; _ui_sub = grpImage.Generate('%s'); assert _ui_sub; grpImage.Delete(_ui_sub)" % sub_path) == "", - "40250 .sub 子图可创建并释放") - _ck(Metin2PythonHost.run_line("_ui_expanded = grpImage.GenerateExpanded('%s'); assert _ui_expanded; grpImage.DeleteExpanded(_ui_expanded)" % image_path) == "", - "40250 ExpandedImage 可创建并释放") - else: - print("SKIP: 40250 image test(pack 缺少 %s)" % image_path) - _ck(Metin2PythonHost.run_line("wndMgr.Destroy(_ui_window)") == "", "窗口已销毁") - else: - print("SKIP: system.py 这一段(没有 40250 Client/pack:%s)" % AssetRoot.client_path()) - - Metin2PythonHost.stop() - _ck(not Metin2PythonHost.is_running(), "stop() 之后 is_running() 为假") - if has_pack: - _run_app_loop() - return fail - -# 2V0-f:有画布尺寸时 app.Create 成功,system.py 停在 app.Loop() 里(脚本线程),每次 ui_update 就是 -# 一次 CPythonApplication::Process()。Logo 阶段 → LoginWindow → 弹窗 ESC 关闭 → stop 走完 RunApp 收尾。 -func _run_app_loop() -> void: - _ck(Metin2PythonHost.start("") == "", "第二次 start()") - Metin2PythonHost.set_ui_size(800, 600) - var err: String = Metin2PythonHost.run_main_script("") - _ck(err == "", "system.py 进入 app.Loop():" + err) - _ck(Metin2PythonHost.is_app_looping(), "is_app_looping() 为真") - _ck(Metin2PythonHost.run_line("import __main__, gc, networkModule, introLogo\n__main__._stream = [o for o in gc.get_objects() if isinstance(o, networkModule.MainStream)][0]\nassert isinstance(_stream.curPhaseWindow, introLogo.LogoWindow)") == "", - "Logo 阶段已打开") - var reached := false - for i in 600: - if not Metin2PythonHost.is_app_looping(): - break - Metin2PythonHost.ui_update() - if Metin2PythonHost.run_line("import introLogin\nif not isinstance(_stream.curPhaseWindow, introLogin.LoginWindow): raise SystemError") == "": - reached = true - break - _ck(reached, "帧推进后进入 LoginWindow") - var glyphs_before := _glyph_quads() - _ck(Metin2PythonHost.run_line("_stream.popupWindow.Open('POPUP_PROBE')\nassert _stream.popupWindow.IsShow()") == "", "PopupDialog 已打开") - Metin2PythonHost.ui_update() - _ck(_glyph_quads() >= glyphs_before + 11, "弹窗文字的字形进入 Godot Canvas 命令队列") - Metin2PythonHost.ui_key(1, true) # DIK_ESCAPE - Metin2PythonHost.ui_key(1, false) - Metin2PythonHost.ui_update() - _ck(Metin2PythonHost.run_line("assert not _stream.popupWindow.IsShow()") == "", "ESC 关闭弹窗") - Metin2PythonHost.stop() - _ck(not Metin2PythonHost.is_running() and not Metin2PythonHost.is_app_looping(), "stop() 结束 app.Loop() 并收尾") - -# CGraphicTextInstance draws one image quad per glyph from a "mem:@" font page. -func _glyph_quads() -> int: - var count := 0 - for draw in Metin2PythonHost.ui_render_commands(): - if draw["kind"] == "image" and String(draw["text"]).begins_with("mem:"): - count += 1 - return count diff --git a/project/python_host_check.gd.uid b/project/python_host_check.gd.uid deleted file mode 100644 index eb64579a..00000000 --- a/project/python_host_check.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://chhb21kpt72wc diff --git a/project/python_host_test.gd b/project/python_host_test.gd deleted file mode 100644 index 4ac78222..00000000 --- a/project/python_host_test.gd +++ /dev/null @@ -1,15 +0,0 @@ -# python_host_test —— 桌面上跑 python_host_check(docs/PORT-PLAN.md 批次 2P step 4)。 -# godot --headless --path project --script python_host_test.gd -# 真机上同一份检查从 client_main.gd 的 MT_TEST_MODE=python 进来。 -extends SceneTree - -const PythonHostCheck = preload("res://python_host_check.gd") - -func _init() -> void: - var fail: int = PythonHostCheck.new().run() - if fail == 0: - print("PASS: python_host_test (app 进程内 system.py)") - quit(0) - else: - printerr("%d check(s) failed" % fail) - quit(1) diff --git a/project/python_host_test.gd.uid b/project/python_host_test.gd.uid deleted file mode 100644 index f8971852..00000000 --- a/project/python_host_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://d1vnobm0q3dhc diff --git a/project/python_stdlib_test.gd b/project/python_stdlib_test.gd deleted file mode 100644 index 56ecccd9..00000000 --- a/project/python_stdlib_test.gd +++ /dev/null @@ -1,77 +0,0 @@ -# python_stdlib_test —— 内嵌解释器标准库的落盘(docs/PORT-PLAN.md 批次 2P step 3c)。 -# 桌面上 res:// 就是磁盘目录,CPython 直接打开;Android/iOS 的 res:// 在 PCK 里,必须先复制进 -# user:// 沙箱并按随包发的 sha256 校验。force_stage=true 就是在桌面上跑那条移动端路径。 -# godot --headless --path project --script python_stdlib_test.gd -extends SceneTree - -const ZIP_USER := "user://python27.zip" -const ZIP_PART := "user://python27.zip.part" - -var _fail := 0 - -func _ck(ok: bool, message: String) -> void: - if not ok: - _fail += 1 - printerr("FAIL: " + message) - -func _init() -> void: - if not ClassDB.class_exists("Metin2Python"): - printerr("FAIL: Metin2Python extension is not registered") - quit(1) - return - if not FileAccess.file_exists("res://python27.zip"): - print("SKIP: python_stdlib_test(没有 res://python27.zip,需要 -DMTGODOT_EMBED_PYTHON=ON 的构建)") - quit(0) - return - - var want := FileAccess.get_file_as_string("res://python27.zip.sha256").strip_edges() - _ck(want.length() == 64, "res://python27.zip.sha256 是一行 sha256") - - # 桌面:res:// 在磁盘上,原地用,不复制。 - if DirAccess.dir_exists_absolute(ProjectSettings.globalize_path("res://")): - var in_place := Metin2Python.stdlib_path(false) - _ck(in_place == ProjectSettings.globalize_path("res://python27.zip"), - "res:// 可直接读时返回原路径,不落盘:" + Metin2Python.last_error()) - - # 移动端路径:复制进沙箱,校验 sha256,返回真实文件系统路径。 - if FileAccess.file_exists(ZIP_USER): - DirAccess.remove_absolute(ZIP_USER) - var staged := Metin2Python.stdlib_path(true) - _ck(staged != "", "落盘成功:" + Metin2Python.last_error()) - _ck(staged == ProjectSettings.globalize_path(ZIP_USER), "返回的是 user:// 的真实路径") - _ck(FileAccess.file_exists(staged), "返回的路径在文件系统上存在") - _ck(FileAccess.get_sha256(ZIP_USER) == want, "落盘的 zip 与随包的 sha256 一致") - _ck(not FileAccess.file_exists(ZIP_PART), "不留下半成品 .part") - _ck(FileAccess.get_file_as_bytes(ZIP_USER) == FileAccess.get_file_as_bytes("res://python27.zip"), - "落盘的字节与 res:// 里的一致") - # zipimport 读不了 deflate(没有 zlib 模块),所以内容必须是 STORED。 - var reader := ZIPReader.new() - var opened := reader.open(staged) - _ck(opened == OK, "落盘的 zip 能打开") - if opened == OK: - _ck(reader.file_exists("encodings/__init__.py"), "zip 里有 encodings 包") - _ck(reader.file_exists("os.py"), "zip 里有 os.py") - reader.close() - - # 第二次启动:已经在沙箱里且 sha 对得上,直接复用。 - var modified := FileAccess.get_modified_time(ProjectSettings.globalize_path(ZIP_USER)) - var again := Metin2Python.stdlib_path(true) - _ck(again == staged, "第二次返回同一路径") - _ck(FileAccess.get_modified_time(ProjectSettings.globalize_path(ZIP_USER)) == modified, - "sha 相符时不重新复制") - - # 上次启动被杀在半路 / 换了一版构建:沙箱里的副本对不上就重新落盘。 - var f := FileAccess.open(ZIP_USER, FileAccess.WRITE) - f.store_string("torn") - f.close() - _ck(FileAccess.get_sha256(ZIP_USER) != want, "已经把沙箱里的副本弄坏") - var restaged := Metin2Python.stdlib_path(true) - _ck(restaged == staged, "坏副本之后仍返回同一路径:" + Metin2Python.last_error()) - _ck(FileAccess.get_sha256(ZIP_USER) == want, "坏副本被重新落盘覆盖") - - if _fail == 0: - print("PASS: python_stdlib_test (res:// -> user:// staging + sha256)") - quit(0) - else: - printerr("%d check(s) failed" % _fail) - quit(1) diff --git a/project/python_stdlib_test.gd.uid b/project/python_stdlib_test.gd.uid deleted file mode 100644 index eea6efcc..00000000 --- a/project/python_stdlib_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cfmd68ffjkrtv diff --git a/project/python_ui_surface.gd b/project/python_ui_surface.gd deleted file mode 100644 index 04303572..00000000 --- a/project/python_ui_surface.gd +++ /dev/null @@ -1,553 +0,0 @@ -# Canvas adapter for the 40250 CWindowManager. The window tree remains native; -# this Control forwards Godot input and draws the native UI command stream. -extends Control - -const AssetRoot = preload("res://asset_root.gd") -const UiAssets = preload("res://ui/ui_assets.gd") - -const KEY_TO_DIK := { - KEY_ESCAPE: 0x01, KEY_1: 0x02, KEY_2: 0x03, KEY_3: 0x04, - KEY_4: 0x05, KEY_5: 0x06, KEY_6: 0x07, KEY_7: 0x08, - KEY_8: 0x09, KEY_9: 0x0a, KEY_0: 0x0b, KEY_MINUS: 0x0c, - KEY_EQUAL: 0x0d, KEY_BACKSPACE: 0x0e, KEY_TAB: 0x0f, - KEY_Q: 0x10, KEY_W: 0x11, KEY_E: 0x12, KEY_R: 0x13, - KEY_T: 0x14, KEY_Y: 0x15, KEY_U: 0x16, KEY_I: 0x17, - KEY_O: 0x18, KEY_P: 0x19, KEY_BRACKETLEFT: 0x1a, KEY_BRACKETRIGHT: 0x1b, - KEY_ENTER: 0x1c, KEY_CTRL: 0x1d, KEY_A: 0x1e, KEY_S: 0x1f, - KEY_D: 0x20, KEY_F: 0x21, KEY_G: 0x22, KEY_H: 0x23, - KEY_J: 0x24, KEY_K: 0x25, KEY_L: 0x26, KEY_SEMICOLON: 0x27, - KEY_APOSTROPHE: 0x28, KEY_QUOTELEFT: 0x29, KEY_SHIFT: 0x2a, - KEY_BACKSLASH: 0x2b, KEY_Z: 0x2c, KEY_X: 0x2d, KEY_C: 0x2e, - KEY_V: 0x2f, KEY_B: 0x30, KEY_N: 0x31, KEY_M: 0x32, - KEY_COMMA: 0x33, KEY_PERIOD: 0x34, KEY_SLASH: 0x35, - KEY_ALT: 0x38, KEY_SPACE: 0x39, - KEY_F1: 0x3b, KEY_F2: 0x3c, KEY_F3: 0x3d, KEY_F4: 0x3e, - KEY_F5: 0x3f, KEY_F6: 0x40, KEY_F7: 0x41, KEY_F8: 0x42, - KEY_F9: 0x43, KEY_F10: 0x44, KEY_F11: 0x57, KEY_F12: 0x58, - KEY_HOME: 0xc7, KEY_UP: 0xc8, KEY_PAGEUP: 0xc9, - KEY_LEFT: 0xcb, KEY_RIGHT: 0xcd, KEY_END: 0xcf, - KEY_DOWN: 0xd0, KEY_PAGEDOWN: 0xd1, KEY_INSERT: 0xd2, - KEY_DELETE: 0xd3, -} - -# Win32 virtual-key codes of the keys EditLine.OnIMEKeyDown handles (WM_KEYDOWN → OnIMEKeyDown); -# letters and digits use their ASCII code, as VK_A..VK_Z / VK_0..VK_9 do. -const KEY_TO_VK := { - KEY_BACKSPACE: 0x08, KEY_TAB: 0x09, KEY_ENTER: 0x0d, KEY_KP_ENTER: 0x0d, - KEY_ESCAPE: 0x1b, KEY_SPACE: 0x20, KEY_END: 0x23, KEY_HOME: 0x24, - KEY_LEFT: 0x25, KEY_UP: 0x26, KEY_RIGHT: 0x27, KEY_DOWN: 0x28, KEY_DELETE: 0x2e, -} - -# The control characters Windows turns these keys into (WM_CHAR); Godot reports them with unicode 0. -const KEY_TO_CHAR := { - KEY_BACKSPACE: 0x08, KEY_TAB: 0x09, KEY_ENTER: 0x0d, KEY_KP_ENTER: 0x0d, KEY_ESCAPE: 0x1b, -} - -# "mem:" → [revision, ImageTexture]: the CGraphicFontTexture glyph pages, refetched when the -# "@" of a command's name moves on (a glyph was added to the page). -var _memory_textures := {} -var _tex_info_cache := {} -var _color_cache := {} -var _white_color_array := PackedColorArray([Color.WHITE]) -var _asset_root_path := "" -var _segments_used := 0 - -# True when this surface started system.py itself (run_app) and so owns the interpreter's lifetime. -var _owns_app := false - -# 40250 WinMain → RunMainScript: start the interpreter, give it the canvas size (app.Create reads it), -# run system.py until it parks in app.Loop(). Returns "" or the error RunApp reported. -func run_app() -> String: - if not AssetRoot.pack_ready(): - return "no 40250 Client/pack at %s" % AssetRoot.client_path() - # The exported Godot executable starts in Contents/Resources. 40250 writes mouse.cfg, - # metin2.cfg and other player settings relative to CWD, so move those writes to user://. - var data_dir := OS.get_user_data_dir() - if DirAccess.make_dir_recursive_absolute(data_dir) != OK: - return "cannot create user data directory at %s" % data_dir - OS.set_environment("MT_40250_CLIENT", AssetRoot.client_path()) - OS.set_environment("MT_40250_USER_DATA_DIR", data_dir) - var err: String = Metin2PythonHost.start("") - if err != "": - return err - _sync_size() - err = Metin2PythonHost.run_main_script("") - if err != "": - Metin2PythonHost.stop() - return err - _owns_app = true - return "" - -func _exit_tree() -> void: - for segment in _segments: - RenderingServer.free_rid(segment) - _segments.clear() - for material in _materials: - RenderingServer.free_rid(material) - _materials.clear() - if _mask_shader.is_valid(): - RenderingServer.free_rid(_mask_shader) - _mask_shader = RID() - if _owns_app: - _owns_app = false - Metin2PythonHost.stop() - -func _ready() -> void: - set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) - mouse_filter = Control.MOUSE_FILTER_STOP - texture_repeat = CanvasItem.TEXTURE_REPEAT_ENABLED - _asset_root_path = AssetRoot.path() - _sync_size() - -func _notification(what: int) -> void: - if what == NOTIFICATION_RESIZED and is_inside_tree(): - _sync_size() - -func _sync_size() -> void: - if ClassDB.class_exists("Metin2PythonHost"): - # Before the first layout pass a full-rect Control is still 0x0; it will fill the viewport. - var canvas := size if size.x > 0 and size.y > 0 else get_viewport_rect().size - Metin2PythonHost.set_ui_size(int(canvas.x), int(canvas.y)) - -func _process(_delta: float) -> void: - # app.Exit()/Abort(): Loop() returned and RunApp() finished; 40250 WinMain then ends the process. - if _owns_app and not Metin2PythonHost.is_app_looping(): - _owns_app = false - Metin2PythonHost.stop() - get_tree().quit(0) - return - if Metin2PythonHost.is_running(): - var profile_start := Time.get_ticks_usec() if _profile_frame else 0 - Metin2PythonHost.ui_update() - if profile_start != 0: - profile_ui_update_ms = float(Time.get_ticks_usec() - profile_start) / 1000.0 - queue_redraw() - -func _gui_input(event: InputEvent) -> void: - if not Metin2PythonHost.is_running(): - return - if event is InputEventMouseMotion: - Metin2PythonHost.ui_mouse_move(int(event.position.x), int(event.position.y)) - elif event is InputEventMouseButton: - if event.button_index >= MOUSE_BUTTON_LEFT and event.button_index <= MOUSE_BUTTON_MIDDLE: - Metin2PythonHost.ui_mouse_button(event.button_index, event.pressed, - int(event.position.x), int(event.position.y)) - elif event.pressed: - if event.button_index == MOUSE_BUTTON_WHEEL_UP: - var factor: float = event.factor if event.factor > 0.0 else 1.0 - Metin2PythonHost.ui_mouse_wheel(int(round(120.0 * factor))) - elif event.button_index == MOUSE_BUTTON_WHEEL_DOWN: - var factor: float = event.factor if event.factor > 0.0 else 1.0 - Metin2PythonHost.ui_mouse_wheel(-int(round(120.0 * factor))) - elif event is InputEventPanGesture: - # macOS trackpad two-finger scroll: scrolling up has negative delta.y, zooming in - var delta: int = -int(round(event.delta.y * 30.0)) - if delta != 0: - Metin2PythonHost.ui_mouse_wheel(delta) - elif event is InputEventMagnifyGesture: - # macOS trackpad pinch to zoom: factor > 1.0 is zoom in - var delta: int = int(round((event.factor - 1.0) * 600.0)) - if delta != 0: - Metin2PythonHost.ui_mouse_wheel(delta) - -func _unhandled_key_input(event: InputEvent) -> void: - if event is InputEventKey and Metin2PythonHost.is_running(): - var code: int = event.physical_keycode - var dik: int = KEY_TO_DIK.get(code, 0) - if dik == 0 and event.keycode != 0: - dik = KEY_TO_DIK.get(event.keycode, 0) - if dik != 0: - Metin2PythonHost.ui_key(dik, event.pressed) - if event.pressed: - _ime_key(event) - -# 40250 gets every key press twice more through the window procedure: WM_KEYDOWN (a VK code, for -# OnIMEKeyDown) and then WM_CHAR (the typed character, for CPythonIME). Held keys repeat both. -func _ime_key(event: InputEventKey) -> void: - var keycode: int = event.keycode - var vk: int = KEY_TO_VK.get(keycode, 0) - if vk == 0 and ((keycode >= KEY_A and keycode <= KEY_Z) or (keycode >= KEY_0 and keycode <= KEY_9)): - vk = keycode - if vk != 0: - Metin2PythonHost.ui_ime_key(vk) - var ch: int = event.unicode - if ch == 0: - ch = KEY_TO_CHAR.get(keycode, 0) - if ch != 0: - Metin2PythonHost.ui_char(ch) - -# The command stream is drawn into pooled child canvas items in order: a run of plain commands -# shares one item; a masked image (CPythonMiniMap's terrain tiles under minimap_image_filter.dds) -# gets its own item carrying the mask shader, since a canvas item has a single material. -const MASK_SHADER := """ -shader_type canvas_item; -render_mode blend_mix; -uniform sampler2D mask_texture : repeat_disable, filter_linear; -uniform vec4 uv_rect; // su, sv, eu, ev of the image -uniform vec2 m0; // mask coordinates at the image's TL, TR and BL corners -uniform vec2 m1; -uniform vec2 m2; -void fragment() { - vec2 ab = (UV - uv_rect.xy) / max(uv_rect.zw - uv_rect.xy, vec2(1e-6)); - vec4 mask = texture(mask_texture, m0 + (m1 - m0) * ab.x + (m2 - m0) * ab.y); - vec4 image = texture(TEXTURE, UV) * COLOR; - COLOR = vec4(image.rgb * mask.rgb, mask.a * COLOR.a); -} -""" - -var _segments: Array[RID] = [] -var _materials: Array[RID] = [] -var _mask_shader := RID() -var _white: ImageTexture -var _profile_frame := OS.has_environment("MT_PROFILE_FRAME") -var profile_ui_update_ms := 0.0 -var profile_draw_ms := 0.0 - -func _get_color_array(argb: int) -> PackedColorArray: - if argb == 0xFFFFFFFF or argb == -1: - return _white_color_array - var arr: PackedColorArray = _color_cache.get(argb, PackedColorArray()) - if not arr.is_empty(): - return arr - var c := Color8((argb >> 16) & 255, (argb >> 8) & 255, argb & 255, (argb >> 24) & 255) - arr = PackedColorArray([c]) - _color_cache[argb] = arr - return arr - -func _get_tex_draw_info(name: String) -> Array: - var info: Array = _tex_info_cache.get(name, []) - if not info.is_empty(): - return info - var texture: Texture2D = _texture(name) - if texture == null: - _tex_info_cache[name] = [] - return [] - var tex_rid: RID = texture.get_rid() - var is_atlas := false - var u0 := 0.0 - var v0 := 0.0 - var du := 1.0 - var dv := 1.0 - var default_uv := PackedVector2Array() - if texture is AtlasTexture: - var at: AtlasTexture = texture - if at.atlas != null: - tex_rid = at.atlas.get_rid() - var asize: Vector2 = at.atlas.get_size() - if asize.x > 0 and asize.y > 0: - is_atlas = true - var r: Rect2 = at.region - u0 = r.position.x / asize.x - v0 = r.position.y / asize.y - du = r.size.x / asize.x - dv = r.size.y / asize.y - default_uv = PackedVector2Array([ - Vector2(u0, v0), - Vector2(u0 + du, v0), - Vector2(u0 + du, v0 + dv), - Vector2(u0, v0 + dv) - ]) - if default_uv.is_empty(): - default_uv = PackedVector2Array([ - Vector2(0, 0), - Vector2(1, 0), - Vector2(1, 1), - Vector2(0, 1) - ]) - info = [tex_rid, is_atlas, u0, v0, du, dv, default_uv, texture] - _tex_info_cache[name] = info - return info - -func _draw() -> void: - var profile_start := Time.get_ticks_usec() if _profile_frame else 0 - var fg_used := 0 - var fg_plain := false - var masked := 0 - - for i in _segments_used: - RenderingServer.canvas_item_clear(_segments[i]) - _segments_used = 0 - - if not Metin2PythonHost.is_running(): - return - - var has_3d: bool = Metin2PythonHost.has_3d_draws() - - for command in Metin2PythonHost.ui_render_commands_batched(): - # Skip background UI commands when 3D is active, as the 3D surface renders the background Quad - if has_3d and bool(command.get(&"behind_3d", false)): - continue - var kind: Variant = command[&"kind"] - if kind == &"glyph_batch": - var info := _get_tex_draw_info(command[&"text"]) - if info.is_empty(): - continue - if not fg_plain: - fg_used = _segment(fg_used, RID()) - fg_plain = true - RenderingServer.canvas_item_add_triangle_array(_segments[fg_used - 1], - command[&"indices"], command[&"points"], command[&"colors"], - command[&"uvs"], PackedInt32Array(), PackedFloat32Array(), info[0]) - continue - - var x1: float = command[&"x1"] - var y1: float = command[&"y1"] - var x2: float = command[&"x2"] - var y2: float = command[&"y2"] - var cx1: float = command[&"clip_x1"] - var cy1: float = command[&"clip_y1"] - var cx2: float = command[&"clip_x2"] - var cy2: float = command[&"clip_y2"] - - # 1) Completely culled by scissor? - if x2 <= cx1 or x1 >= cx2 or y2 <= cy1 or y1 >= cy2: - continue - - var argb: int = command[&"argb"] - var target_segments := _segments - - if kind == &"image": - var text_name: String = command.get(&"text", "") - var mask_name: String = command.get(&"mask", "") - var mask_tex: Texture2D = _texture(mask_name) if not mask_name.is_empty() else null - if text_name.is_empty() and mask_tex != null: - text_name = "__white__" - - var info := _get_tex_draw_info(text_name) - if info.is_empty(): - continue - var tex_rid: RID = info[0] - var is_atlas: bool = info[1] - var u0: float = info[2] - var v0: float = info[3] - var du: float = info[4] - var dv: float = info[5] - - var quad: PackedVector2Array = command[&"quad"] - var quad_uv: PackedVector2Array = command[&"uv"] - - var needs_clip := x1 < cx1 or x2 > cx2 or y1 < cy1 or y2 > cy2 - var poly_pts: PackedVector2Array - var poly_uv: PackedVector2Array - - if not needs_clip: - poly_pts = PackedVector2Array([quad[0], quad[1], quad[3], quad[2]]) - var is_full_uv := quad_uv[0] == Vector2.ZERO and quad_uv[1] == Vector2(1, 0) and quad_uv[2] == Vector2(0, 1) and quad_uv[3] == Vector2.ONE - if is_full_uv: - poly_uv = info[6] - elif is_atlas: - poly_uv = PackedVector2Array([ - Vector2(u0 + quad_uv[0].x * du, v0 + quad_uv[0].y * dv), - Vector2(u0 + quad_uv[1].x * du, v0 + quad_uv[1].y * dv), - Vector2(u0 + quad_uv[3].x * du, v0 + quad_uv[3].y * dv), - Vector2(u0 + quad_uv[2].x * du, v0 + quad_uv[2].y * dv) - ]) - else: - poly_uv = PackedVector2Array([quad_uv[0], quad_uv[1], quad_uv[3], quad_uv[2]]) - else: - var mapped_uv: PackedVector2Array - if is_atlas: - mapped_uv = PackedVector2Array([ - Vector2(u0 + quad_uv[0].x * du, v0 + quad_uv[0].y * dv), - Vector2(u0 + quad_uv[1].x * du, v0 + quad_uv[1].y * dv), - Vector2(u0 + quad_uv[2].x * du, v0 + quad_uv[2].y * dv), - Vector2(u0 + quad_uv[3].x * du, v0 + quad_uv[3].y * dv) - ]) - else: - mapped_uv = quad_uv - var clip_rect := Rect2(cx1, cy1, cx2 - cx1, cy2 - cy1) - var clipped := _clip_quad(quad, mapped_uv, clip_rect) - if clipped[0].size() < 3: - continue - poly_pts = clipped[0] - poly_uv = clipped[1] - - if mask_tex != null: - var mat_rid := _mask_material(masked, mask_tex, quad_uv, command[&"mask_uv"]) - fg_used = _segment(fg_used, mat_rid) - fg_plain = false - masked += 1 - elif not fg_plain: - fg_used = _segment(fg_used, RID()) - fg_plain = true - var seg_idx := fg_used - 1 - RenderingServer.canvas_item_add_polygon(target_segments[seg_idx], poly_pts, - _get_color_array(argb), poly_uv, tex_rid) - - elif kind == &"bar" and command.has(&"quad"): - # An untextured polygon piece (CPythonGraphic::RenderCoolTimeBox's fan triangles). - if x2 > cx1 and x1 < cx2 and y2 > cy1 and y1 < cy2: - if not fg_plain: - fg_used = _segment(fg_used, RID()) - fg_plain = true - var seg_idx := fg_used - 1 - var quad: PackedVector2Array = command[&"quad"] - RenderingServer.canvas_item_add_polygon(target_segments[seg_idx], - PackedVector2Array([quad[0], quad[1], quad[2]]), _get_color_array(argb)) - - elif kind == &"bar": - var bx1 := maxf(x1, cx1) - var by1 := maxf(y1, cy1) - var bx2 := minf(x2, cx2) - var by2 := minf(y2, cy2) - if bx2 > bx1 and by2 > by1: - if not fg_plain: - fg_used = _segment(fg_used, RID()) - fg_plain = true - var seg_idx := fg_used - 1 - var color := Color8((argb >> 16) & 255, (argb >> 8) & 255, - argb & 255, (argb >> 24) & 255) - RenderingServer.canvas_item_add_rect(target_segments[seg_idx], - Rect2(bx1, by1, bx2 - bx1, by2 - by1), color) - - elif kind == &"gradient_bar": - var bx1 := maxf(x1, cx1) - var by1 := maxf(y1, cy1) - var bx2 := minf(x2, cx2) - var by2 := minf(y2, cy2) - if bx2 > bx1 and by2 > by1: - if not fg_plain: - fg_used = _segment(fg_used, RID()) - fg_plain = true - var seg_idx := fg_used - 1 - var color := Color8((argb >> 16) & 255, (argb >> 8) & 255, - argb & 255, (argb >> 24) & 255) - var bottom_argb: int = command[&"end_argb"] - var bottom := Color8((bottom_argb >> 16) & 255, (bottom_argb >> 8) & 255, - bottom_argb & 255, (bottom_argb >> 24) & 255) - var top_factor := (by1 - y1) / (y2 - y1) if y2 != y1 else 0.0 - var bottom_factor := (by2 - y1) / (y2 - y1) if y2 != y1 else 1.0 - RenderingServer.canvas_item_add_polygon(target_segments[seg_idx], - PackedVector2Array([Vector2(bx1, by1), Vector2(bx2, by1), - Vector2(bx2, by2), Vector2(bx1, by2)]), - PackedColorArray([color.lerp(bottom, top_factor), color.lerp(bottom, top_factor), - color.lerp(bottom, bottom_factor), color.lerp(bottom, bottom_factor)])) - - elif kind == &"line": - var clip_rect := Rect2(cx1, cy1, cx2 - cx1, cy2 - cy1) - var p1 := Vector2(x1, y1) - var p2 := Vector2(x2, y2) - var segment := _clip_line(p1, p2, clip_rect) - if segment.size() == 2: - if not fg_plain: - fg_used = _segment(fg_used, RID()) - fg_plain = true - var seg_idx := fg_used - 1 - var color := Color8((argb >> 16) & 255, (argb >> 8) & 255, - argb & 255, (argb >> 24) & 255) - RenderingServer.canvas_item_add_line(target_segments[seg_idx], segment[0], segment[1], color) - - _segments_used = fg_used - if profile_start != 0: - profile_draw_ms = float(Time.get_ticks_usec() - profile_start) / 1000.0 - -# Opens the next pooled child item (draw order = index) with the given material; returns the count used. -func _segment(used: int, material: RID) -> int: - var pool := _segments - var parent_item: RID = get_canvas_item() - if used == pool.size(): - var item := RenderingServer.canvas_item_create() - RenderingServer.canvas_item_set_parent(item, parent_item) - RenderingServer.canvas_item_set_default_texture_repeat(item, RenderingServer.CANVAS_ITEM_TEXTURE_REPEAT_ENABLED) - pool.push_back(item) - var item := pool[used] - RenderingServer.canvas_item_set_draw_index(item, used) - RenderingServer.canvas_item_set_material(item, material) - return used + 1 - -func _mask_material(index: int, mask: Texture2D, uv: PackedVector2Array, mask_uv: PackedVector2Array) -> RID: - if not _mask_shader.is_valid(): - _mask_shader = RenderingServer.shader_create() - RenderingServer.shader_set_code(_mask_shader, MASK_SHADER) - if index == _materials.size(): - var material := RenderingServer.material_create() - RenderingServer.material_set_shader(material, _mask_shader) - _materials.push_back(material) - var material := _materials[index] - RenderingServer.material_set_param(material, "mask_texture", mask.get_rid()) - RenderingServer.material_set_param(material, "uv_rect", Vector4(uv[0].x, uv[0].y, uv[3].x, uv[3].y)) - RenderingServer.material_set_param(material, "m0", mask_uv[0]) - RenderingServer.material_set_param(material, "m1", mask_uv[1]) - RenderingServer.material_set_param(material, "m2", mask_uv[2]) - return material - -func _white_texture() -> Texture2D: - if _white == null: - var image := Image.create(1, 1, false, Image.FORMAT_RGBA8) - image.fill(Color.WHITE) - _white = ImageTexture.create_from_image(image) - return _white - -func _texture(name: String) -> Texture2D: - if name == "__white__": - return _white_texture() - if not name.begins_with("mem:"): - var root := _asset_root_path if not _asset_root_path.is_empty() else AssetRoot.path() - return UiAssets.load_tex(root, name) - var at := name.find("@") - var key := name.substr(0, at) if at >= 0 else name - var revision := int(name.substr(at + 1)) if at >= 0 else 0 - var entry: Array = _memory_textures.get(key, []) - if not entry.is_empty() and entry[0] == revision: - return entry[1] - var image: Image = Metin2PythonHost.memory_texture(name, -1) - if image == null: - _memory_textures.erase(key) - return null - var texture: ImageTexture = entry[1] if not entry.is_empty() else null - if texture != null and texture.get_size() == Vector2(image.get_size()): - texture.update(image) - else: - texture = ImageTexture.create_from_image(image) - _memory_textures[key] = [revision, texture] - return texture - -# 40250's image quad (TL, TR, BL, BR) clipped to the viewport. The quad is a -# parallelogram, so texture coordinates are an affine function of position and are recomputed for -# the clipped outline. Returns [points, uvs]; empty points when nothing is visible. -# PORT: 40250's SCREEN / COLOR_DODGE / MODULATE blend states (command["blend"]) are not applied yet; -# the canvas draws every quad with normal alpha blending. -func _clip_quad(quad: PackedVector2Array, uv: PackedVector2Array, clip: Rect2) -> Array: - var outline := PackedVector2Array([quad[0], quad[1], quad[3], quad[2]]) - var edge_u := quad[1] - quad[0] - var edge_v := quad[2] - quad[0] - var det := edge_u.cross(edge_v) - if is_zero_approx(det): - return [PackedVector2Array(), PackedVector2Array()] - if clip.has_point(quad[0]) and clip.has_point(quad[1]) \ - and clip.has_point(quad[2]) and clip.has_point(quad[3]): - return [outline, PackedVector2Array([uv[0], uv[1], uv[3], uv[2]])] - var rect := PackedVector2Array([clip.position, Vector2(clip.end.x, clip.position.y), - clip.end, Vector2(clip.position.x, clip.end.y)]) - var pieces := Geometry2D.intersect_polygons(outline, rect) - if pieces.is_empty(): - return [PackedVector2Array(), PackedVector2Array()] - var points: PackedVector2Array = pieces[0] - var uvs := PackedVector2Array() - for point in points: - var d := point - quad[0] - var a := d.cross(edge_v) / det - var b := edge_u.cross(d) / det - uvs.push_back(uv[0] + (uv[1] - uv[0]) * a + (uv[2] - uv[0]) * b) - return [points, uvs] - -func _clip_line(a: Vector2, b: Vector2, clip: Rect2) -> PackedVector2Array: - var delta := b - a - var p := [-delta.x, delta.x, -delta.y, delta.y] - var q := [a.x - clip.position.x, clip.end.x - a.x, - a.y - clip.position.y, clip.end.y - a.y] - var enter := 0.0 - var leave := 1.0 - for i in range(4): - if is_zero_approx(p[i]): - if q[i] < 0.0: - return PackedVector2Array() - else: - var t: float = q[i] / p[i] - if p[i] < 0.0: - enter = maxf(enter, t) - else: - leave = minf(leave, t) - if enter > leave: - return PackedVector2Array() - return PackedVector2Array([a + delta * enter, a + delta * leave]) diff --git a/project/python_ui_surface.gd.uid b/project/python_ui_surface.gd.uid deleted file mode 100644 index 8b678107..00000000 --- a/project/python_ui_surface.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bi1ruj2meedvx diff --git a/project/sample_profile.gd b/project/sample_profile.gd deleted file mode 100644 index 71311357..00000000 --- a/project/sample_profile.gd +++ /dev/null @@ -1,14 +0,0 @@ -@tool -extends SceneTree - -func _init() -> void: - var tw = Metin2World.new() - tw.set("assets_root", "pack://") - tw.set("map_path", "metin2_map_a1") - tw.set("auto_load", false) - tw.call("set_focus_tile", Vector2i(2, 2)) - if tw.call("load_map"): - for y in range(68200, 66000, -100): - var h = float(tw.call("sample_height", 59700.0 / 100.0, float(y) / 100.0)) * 100.0 - print("y = %d -> height = %.2f" % [y, h]) - quit(0) diff --git a/project/sample_profile.gd.uid b/project/sample_profile.gd.uid deleted file mode 100644 index 6b64993e..00000000 --- a/project/sample_profile.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bt4n4goaei8mi diff --git a/project/test_actor_heights.gd b/project/test_actor_heights.gd deleted file mode 100644 index f3c9df2b..00000000 --- a/project/test_actor_heights.gd +++ /dev/null @@ -1,107 +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(60, func(): return _py( - "assert isinstance(_stream.curPhaseWindow, game.GameWindow) and _stream.curPhaseWindow.IsShow()" - )) - - await _pump_until(10, func(): return world.draw_count > 0) - for i in 30: - await process_frame - - var draws: Array = Metin2PythonHost.render3d_draws() - print("Game 3D draws count: ", draws.size()) - - # Find actual actors - var actor_draws: Array = [] - for draw in draws: - var tex = draw.get("texture0", "").to_lower() - var is_actor = false - for k in ["pc/", "pc2/", "monster/", "npc/", "warrior", "assassin", "sura", "shaman", "dog", "wolf"]: - if k in tex: - is_actor = true - break - if is_actor: - actor_draws.append(draw) - - print("Actual actor draws count: ", actor_draws.size()) - - var actor_low := {} - for draw in actor_draws: - var draw_xform: Transform3D = world.d3d_transform(draw["world"]) - var key := str(draw["world"]) - for vertex in draw["positions"]: - var p: Vector3 = draw_xform * vertex - if not actor_low.has(key) or p.z < actor_low[key].z: - actor_low[key] = p - - print("Distinct actors count: ", actor_low.size()) - if world.terrain: - var worst_actor_gap_cm := 0.0 - for key in actor_low: - var low: Vector3 = actor_low[key] - var ground := float(world.terrain.call("sample_height", low.x / 100.0, -low.y / 100.0)) * 100.0 - var gap = absf(low.z - ground) - print("Actor at (%f, %f): foot_z=%f, ground_z=%f, gap=%f cm" % [low.x, low.y, low.z, ground, gap]) - worst_actor_gap_cm = maxf(worst_actor_gap_cm, gap) - print("WORST ACTUAL ACTOR GAP: ", worst_actor_gap_cm, " cm") - - quit(0) diff --git a/project/test_actor_heights.gd.uid b/project/test_actor_heights.gd.uid deleted file mode 100644 index 2207d789..00000000 --- a/project/test_actor_heights.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cmum40u4abuep diff --git a/project/test_basis.gd b/project/test_basis.gd deleted file mode 100644 index ab1f0dc6..00000000 --- a/project/test_basis.gd +++ /dev/null @@ -1,13 +0,0 @@ -@tool -extends SceneTree - -func _init() -> void: - var b := Basis(Vector3(1, 2, 3), Vector3(4, 5, 6), Vector3(7, 8, 9)) - print("b.x = ", b.x) - print("b.y = ", b.y) - print("b.z = ", b.z) - var v := Vector3(1, 0, 0) - print("b * Vector3(1, 0, 0) = ", b * v) - var v2 := Vector3(0, 1, 0) - print("b * Vector3(0, 1, 0) = ", b * v2) - quit(0) diff --git a/project/test_basis.gd.uid b/project/test_basis.gd.uid deleted file mode 100644 index dc4ac18f..00000000 --- a/project/test_basis.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://d17rcsnfyx418 diff --git a/project/test_select_character.gd b/project/test_select_character.gd deleted file mode 100644 index f698b1b2..00000000 --- a/project/test_select_character.gd +++ /dev/null @@ -1,95 +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 != "": - quit(1) - return - - await _pump_until(15, func(): return _py( - "import __main__, os, networkModule, introLogin, introSelect\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" - + "_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)" - )) - - for i in 15: - await process_frame - - var img_final = root.get_texture().get_image() - img_final.save_png("/tmp/select_character_final.png") - print("Saved /tmp/select_character_final.png") - - quit(0) - - diff --git a/project/test_select_character.gd.uid b/project/test_select_character.gd.uid deleted file mode 100644 index 8c489c20..00000000 --- a/project/test_select_character.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dpi4fhed4wc28 diff --git a/project/ui/ui_assets.gd b/project/ui/ui_assets.gd deleted file mode 100644 index dc2d7ec3..00000000 --- a/project/ui/ui_assets.gd +++ /dev/null @@ -1,254 +0,0 @@ -# UiAssets (P1) —— 解析 uiscript 里的图片路径(`.sub` / `.tga` / `.png` / `.jpg` / `.dds`)。 -# -# 路径形如 `d:/ymir work/ui/public/middle_button_01.sub`。解析: -# 1) 优先从 40250 Client/pack(Metin2Pack)取(保持 d:/ 盘符或规范相对路径) -# 2) 支持 pack 内 .sub 子图切片(AtlasTexture),支持 version 1.0 / 2.0 及公用 Public.dds / IntroEmpire.dds 母图寻址 -# 3) 若 pack 找不到且指定了 assets_root,回退到散文件目录 -# 4) .dds 走 Metin2World.load_dds(C++ dxt 解码)运行时解成 Image。 -extends RefCounted - -static var _cache := {} - -static func clear_cache() -> void: - _cache.clear() - -# DDS 运行时解码:走 Metin2World.load_dds(C++ 里的 dxt.cpp,DXT1/3/5 + BGRA8)。 -# Godot 无原生运行时 DDS 解码,UI 里的 select.dds / 职业名图都得走这条路。 -static func load_dds_image(path: String) -> Image: - if path == "" or not FileAccess.file_exists(path): - return null - if not ClassDB.class_exists("Metin2World"): - return null - var helper: Object = ClassDB.instantiate("Metin2World") - var img = helper.call("load_dds", path) - helper.free() - return img if img is Image else null - -static func load_tex(assets_root: String, vpath: String) -> Texture2D: - if vpath == "": - return null - var key := (assets_root if assets_root != "" else "pack") + "|" + vpath - if _cache.has(key): - return _cache[key] - var tex: Texture2D = _load_pack_any(vpath) - if tex == null and assets_root != "": - tex = _load_uncached(assets_root, vpath) - _cache[key] = tex - return tex - -# 只从 40250 Client/pack(Metin2Pack)取,不看 assets/ 散文件:移植的客户端从包里读模型, -# 贴图也必须是同一份(assets/ 里同名的 fork 贴图 UV 布局可能不同)。 -static func load_pack_tex(vpath: String) -> Texture2D: - if vpath == "": - return null - var key := "pack|" + vpath - if _cache.has(key): - return _cache[key] - var tex: Texture2D = _load_pack_any(vpath) - _cache[key] = tex - return tex - -static func _strip_drive(p: String) -> String: - var s := p.replace("\\", "/") - if s.length() >= 2 and s[1] == ":": - s = s.substr(2) - return s.lstrip("/") - -static func _find_pack_path(vpath: String) -> String: - if not ClassDB.class_exists("Metin2Pack") or not Metin2Pack.is_ready(): - return "" - var norm := vpath.replace("\\", "/").strip_edges() - var stripped := _strip_drive(norm) - var candidates: Array[String] = [ - norm, - stripped, - "d:/" + stripped, - norm.to_lower(), - stripped.to_lower(), - ("d:/" + stripped).to_lower(), - ] - for c in candidates: - if Metin2Pack.exists(c): - return c - return "" - -static func _load_pack_any(vpath: String) -> Texture2D: - var pack_path := _find_pack_path(vpath) - if pack_path == "": - return null - var ext := pack_path.get_extension().to_lower() - if ext == "sub": - return _load_pack_sub(pack_path) - return _load_pack_image(pack_path) - -static func _load_pack_sub(pack_path: String) -> Texture2D: - if not ClassDB.class_exists("Metin2Pack") or not Metin2Pack.is_ready(): - return null - if not Metin2Pack.exists(pack_path): - return null - var bytes: PackedByteArray = Metin2Pack.get_bytes(pack_path) - if bytes.is_empty(): - return null - var txt := bytes.get_string_from_ascii() - var image_name := "" - var version := "" - var l := 0 - var t := 0 - var r := -1 - var b := -1 - for line in txt.split("\n"): - var parts := line.strip_edges().split(" ", false) - if parts.size() < 2: - continue - match parts[0].to_lower(): - "version": version = parts[1].strip_edges().trim_prefix('"').trim_suffix('"') - "image": image_name = parts[1].strip_edges().trim_prefix('"').trim_suffix('"') - "left": l = int(parts[1]) - "top": t = int(parts[1]) - "right": r = int(parts[1]) - "bottom": b = int(parts[1]) - if image_name == "": - return null - - var sub_dir := pack_path.get_base_dir() - var candidates: Array[String] = [] - if version == "2.0": - candidates.append(sub_dir.path_join(image_name)) - candidates.append("d:/ymir work/ui/".path_join(image_name)) - else: - candidates.append("d:/ymir work/ui/".path_join(image_name)) - candidates.append(sub_dir.path_join(image_name)) - - var search_dir := sub_dir - for _i in 4: - var parent := search_dir.get_base_dir() - if parent == search_dir or parent.is_empty(): - break - candidates.append(parent.path_join(image_name)) - search_dir = parent - - var base_tex: Texture2D = null - for cand in candidates: - base_tex = _load_pack_any(cand) - if base_tex != null: - break - if base_tex == null: - return null - if r <= l or b <= t: - return base_tex - var at := AtlasTexture.new() - at.atlas = base_tex - at.region = Rect2(l, t, r - l, b - t) - return at - -static func _resolve(assets_root: String, rel: String) -> String: - var direct := assets_root.path_join(rel) - if FileAccess.file_exists(direct): - return direct - # 散包:// - var da := DirAccess.open(assets_root) - if da: - for sub in da.get_directories(): - var cand := assets_root.path_join(sub).path_join(rel) - if FileAccess.file_exists(cand): - return cand - return "" - -static func _load_uncached(assets_root: String, vpath: String) -> Texture2D: - var rel := _strip_drive(vpath) - var real := _resolve(assets_root, rel) - if real == "": - for ext: String in [".tga", ".png", ".jpg"]: - real = _resolve(assets_root, rel.get_basename() + ext) - if real != "": - break - if real == "": - return _load_pack_any(vpath) - if real.get_extension().to_lower() == "sub": - return _load_sub(real) - return _load_image_file(real) - -static func _load_pack_image(rel: String) -> Texture2D: - if not ClassDB.class_exists("Metin2Pack") or not Metin2Pack.is_ready(): - return null - if not Metin2Pack.exists(rel): - return null - var ext := rel.get_extension().to_lower() - var image: Image - if ext == "dds": - if not ClassDB.class_exists("Metin2World"): - return null - var helper: Object = ClassDB.instantiate("Metin2World") - image = helper.call("load_dds", "pack://" + rel) - helper.free() - else: - var bytes: PackedByteArray = Metin2Pack.get_bytes(rel) - image = Image.new() - var err := ERR_FILE_UNRECOGNIZED - match ext: - "tga": err = image.load_tga_from_buffer(bytes) - "png": err = image.load_png_from_buffer(bytes) - "jpg", "jpeg": err = image.load_jpg_from_buffer(bytes) - "bmp": err = image.load_bmp_from_buffer(bytes) - if err != OK: - return null - return ImageTexture.create_from_image(image) if image != null and not image.is_empty() else null - -static func _load_image_file(path: String) -> Texture2D: - if not FileAccess.file_exists(path): - return null - var ext := path.get_extension().to_lower() - if ext == "dds": - var di := load_dds_image(path) - return ImageTexture.create_from_image(di) if di != null else null - var img := Image.new() - if img.load(path) != OK: - return null - return ImageTexture.create_from_image(img) - -static func _load_sub(path: String) -> Texture2D: - var txt := FileAccess.get_file_as_string(path) - var image_name := "" - var l := 0 - var t := 0 - var r := -1 - var b := -1 - for line in txt.split("\n"): - var parts := line.strip_edges().split(" ", false) - if parts.size() < 2: - continue - match parts[0]: - "image": image_name = parts[1].strip_edges().trim_prefix('"').trim_suffix('"') - "left": l = int(parts[1]) - "top": t = int(parts[1]) - "right": r = int(parts[1]) - "bottom": b = int(parts[1]) - if image_name == "": - return null - var img_path := "" - var search_dir := path.get_base_dir() - for _i in 6: - for candidate in [image_name, image_name.get_basename() + ".tga", - image_name.get_basename() + ".png", image_name.get_basename() + ".dds"]: - var candidate_path := search_dir.path_join(candidate) - if FileAccess.file_exists(candidate_path): - img_path = candidate_path - break - if img_path != "": - break - var parent_dir := search_dir.get_base_dir() - if parent_dir == search_dir: - break - search_dir = parent_dir - if not FileAccess.file_exists(img_path): - return null - var base := _load_image_file(img_path) - if base == null: - return null - if r <= l or b <= t: - return base - var at := AtlasTexture.new() - at.atlas = base - var region_top := t - at.region = Rect2(l, region_top, r - l, b - t) - return at diff --git a/project/ui/ui_assets.gd.uid b/project/ui/ui_assets.gd.uid deleted file mode 100644 index 2b7e7584..00000000 --- a/project/ui/ui_assets.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cdmq2ac3w0su6 diff --git a/run-client.command b/run-client.command deleted file mode 100755 index 3d66b3d9..00000000 --- a/run-client.command +++ /dev/null @@ -1,22 +0,0 @@ -#!/usr/bin/env bash -# 双击直接跑完整客户端(优先使用已构建的自包含 .app,亦可回退到已装的 Godot 引擎)。 -# 登录 → 选人 → 进游戏。 -cd "$(dirname "$0")" -REPO="$PWD" -export MT_40250_CLIENT="$REPO/Client" -export MT_PROTOCOL="${MT_PROTOCOL:-classic}" - -APP="$REPO/build/export/mtgodot-poc.app" -if [ -x "$APP/Contents/MacOS/mtgodot-poc" ]; then - echo ">> 启动已构建的自包含客户端: $APP" - exec "$APP/Contents/MacOS/mtgodot-poc" "$@" -fi - -GODOT_BIN="${GODOT:-$(which godot 2>/dev/null || echo "/Applications/Godot.app/Contents/MacOS/Godot")}" -if [ -x "$GODOT_BIN" ]; then - echo ">> 启动 Godot 开发运行环境: $GODOT_BIN" - exec "$GODOT_BIN" --path project res://client_main.tscn "$@" -fi - -echo "错误:未找到可执行的客户端应用 ($APP) 或 Godot 引擎" >&2 -exit 1 diff --git a/script/audit_native_trees.mjs b/script/audit_native_trees.mjs deleted file mode 100644 index 217e0807..00000000 --- a/script/audit_native_trees.mjs +++ /dev/null @@ -1,43 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import crypto from 'node:crypto'; -import {execFileSync} from 'node:child_process'; -const assets = process.argv[2] ?? 'assets'; -const output = process.argv[3]; -const manifest = JSON.parse(fs.readFileSync(path.join(assets, 'TreeGeometry/manifest.json'))); -const normalize = s => s.replaceAll('\\', '/').toLowerCase().replace(/^.*?ymir work\//, 'ymir work/'); -const converted = new Set(Object.values(manifest.trees).map(r => normalize(r.source))); -const list = pattern => execFileSync('rg', ['--files', assets, '-g', pattern], {encoding: 'utf8', maxBuffer: 8 * 1024 * 1024}).trim().split('\n').filter(Boolean); -const references = new Set(); -const propertyReferences = new Map(); -for (const file of list('*.prt')) { - const source = fs.readFileSync(file, 'utf8'); - for (const match of source.matchAll(/^\s*treefile\s+"([^"]+)"/gmi)) { - const reference = normalize(match[1]); - references.add(reference); - propertyReferences.set(source.split(/\r?\n/)[1].trim(), reference); - } -} -const placedReferences = new Set(); -for (const file of list('areadata.txt')) { - const source = fs.readFileSync(file, 'utf8'); - for (const match of source.matchAll(/^Start Object\d+\r?\n[^\n]*\n\s*(\d+)/gm)) { - const reference = propertyReferences.get(match[1]); - if (reference) placedReferences.add(reference); - } -} -const unmatched = []; -let matched = 0; -for (const file of list('*.spt')) { - const hash = crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); - if (manifest.trees[hash]) matched++; - else unmatched.push({file, source_sha256: hash, referenced_by_property: references.has(normalize(file))}); -} -const missing_referenced = [...references].filter(r => !converted.has(r)).sort(); -const missing_placed = [...placedReferences].filter(r => !converted.has(r)).sort(); -const report = {matched_spt_files: matched, converted_species: converted.size, referenced_species: references.size, - placed_species: placedReferences.size, missing_placed, - missing_referenced, unconverted_spt: unmatched, scope: 'SPT inventory and text PRT/AreaData references; six-map runtime validation is separate'}; -if (output) fs.writeFileSync(output, JSON.stringify(report, null, 2) + '\n'); -console.log(JSON.stringify({matched, converted: converted.size, referenced: references.size, placed: placedReferences.size, missing_placed, missing_referenced, unconverted: unmatched.length})); -if (!placedReferences.size || missing_placed.length) process.exitCode = 1; diff --git a/script/audit_playable_maps.mjs b/script/audit_playable_maps.mjs deleted file mode 100644 index 3ffcae2a..00000000 --- a/script/audit_playable_maps.mjs +++ /dev/null @@ -1,445 +0,0 @@ -#!/usr/bin/env node -/* Static prerequisite audit for the first playable-map fixture (FIRST-MAC-PLAYABLE §8.1). - * It reads the real map setting, TextureSet, AreaData/Property, npclist, MSM and motlist, - * resolves every referenced file the way the runtime does, and lists missing items. - * It does not connect to a server and cannot prove which monsters a live server spawned: - * tree monsters are reported as candidates only. - * - * exit: 0 = no unexplained gaps, 1 = missing resources, 2 = config/precondition blocked. */ -import fs from 'node:fs'; -import path from 'node:path'; -import crypto from 'node:crypto'; - -const USAGE = `usage: audit_playable_maps.mjs [--config SCENARIO.json | --maps DIR[,DIR...]] [--assets DIR] [--races N[,N...]] [--output FILE] - --config scenario JSON; its map_key is audited (credentials are never read from it) - --maps explicit map directories relative to the asset root - --assets asset root (default: assets) - --races candidate monster races (default: npclist entries 2301-2315) - --output report path (default: build/playable/map-assets.json)`; - -const DEFAULT_MAPS = ['outdoortrent/metin2_map_trent', 'outdoortrent02/metin2_map_trent02']; -const PROPERTY_EXTENSIONS = ['.prb', '.prt', '.pre', '.prd', '.pra']; -// Keep in sync with project/ui/mob_view.gd STATE_MOTIONS (first name = primary motion). -const STATE_MOTIONS = { - wait: ['WAIT', 'WAIT1'], - run: ['RUN', 'WALK'], - attack: ['NORMAL_ATTACK', 'NORMAL_ATTACK1', 'SPECIAL_1'], - damage: ['FRONT_DAMAGE', 'DAMAGE', 'BACK_DAMAGE'], - dead: ['FRONT_DEAD', 'DEAD', 'BACK_DEAD'], -}; -// Reference CActorInstance::Move -> SetLoopMotion(RUN/WALK): a GetMotionKey miss returns early -// and the current loop keeps playing. Only these states may be explained by that rule. -const REFERENCE_KEEP_CURRENT_STATES = ['run']; -const TILE_FILES = ['areadata.txt', 'height.raw', 'tile.raw', 'attr.atr']; - -class Blocked extends Error {} - -function parseArgs(argv) { - const out = { assets: 'assets', output: 'build/playable/map-assets.json', maps: '', config: '', races: '' }; - for (let i = 0; i < argv.length; i += 1) { - const a = argv[i]; - if (a === '--help' || a === '-h') { console.log(USAGE); process.exit(0); } - if (!a.startsWith('--') || i + 1 >= argv.length) throw new Blocked(`invalid argument: ${a}`); - const k = a.slice(2).replaceAll('-', '_'); - if (!(k in out)) throw new Blocked(`unknown option: ${a}`); - out[k] = argv[++i]; - } - if (out.config && out.maps) throw new Blocked('use either --config or --maps, not both'); - return out; -} - -const posix = value => value.replaceAll('\\', '/'); -const read = file => fs.readFileSync(file, 'latin1'); -const sha256 = file => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); - -// Mirrors fmt::AssetResolver: files are indexed by their asset-root relative path and by the -// suffix after "ymir work/", with the pack priority deciding which real file wins. -class AssetIndex { - constructor(root) { - this.root = root; - this.byRel = new Map(); - this.byYmir = new Map(); - const priority = ['zone', 'terrain', 'etc', 'pc', 'tree', 'property', 'effect', 'monster', 'npc', 'season2', 'season3_eu']; - const rank = rel => { - const top = rel.split('/')[0].toLowerCase(); - const i = priority.indexOf(top); - if (i >= 0) return i; - return top.startsWith('metin2_patch') ? 2000 : 1000; - }; - const files = []; - const visit = dir => { - for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { - const full = path.join(dir, entry.name); - if (entry.isDirectory()) visit(full); else files.push(posix(path.relative(root, full))); - } - }; - if (fs.existsSync(root)) visit(root); - files.sort((a, b) => rank(a) - rank(b) || (a < b ? -1 : a > b ? 1 : 0)); - for (const rel of files) { - const lower = rel.toLowerCase(); - if (!this.byRel.has(lower)) this.byRel.set(lower, rel); - const at = lower.indexOf('ymir work/'); - if (at >= 0) { - const key = lower.slice(at + 'ymir work/'.length); - if (!this.byYmir.has(key)) this.byYmir.set(key, rel); - } - } - this.files = files; - this.dirs = new Set(); - for (const rel of files) { - const parts = rel.toLowerCase().split('/'); - for (let i = 1; i < parts.length; i += 1) this.dirs.add(parts.slice(0, i).join('/')); - } - this.topDirs = [...new Set(files.filter(rel => rel.includes('/')).map(rel => rel.split('/')[0]))].sort(); - } - - hasDir(relDir) { return this.dirs.has(AssetIndex.normalize(relDir)); } - - static normalize(virtualPath) { - let s = posix(String(virtualPath || '')).toLowerCase(); - if (/^[a-z]:/.test(s)) s = s.slice(2); - return s.replace(/^\/+/, '').replace(/\/{2,}/g, '/'); - } - - // Returns the asset-root relative real path, or ''. - resolve(virtualPath) { - const s = AssetIndex.normalize(virtualPath); - if (!s) return ''; - const at = s.indexOf('ymir work/'); - if (at >= 0) return this.byYmir.get(s.slice(at + 'ymir work/'.length)) || ''; - return this.byRel.get(s) || ''; - } - - rel(relPath) { return this.byRel.get(AssetIndex.normalize(relPath)) || ''; } - abs(rel) { return path.join(this.root, rel); } -} - -function parseKeyValues(text) { - const out = {}; - for (const line of text.split(/\r?\n/)) { - const m = line.trim().match(/^([A-Za-z_][\w]*)\s+(.*)$/); - if (m && !(m[1].toLowerCase() in out)) out[m[1].toLowerCase()] = m[2].trim(); - } - return out; -} - -// Property file: line 1 "YPRT", line 2 CRC, then key "value" pairs (formats/property.h). -function parseProperty(index, rel) { - const lines = read(index.abs(rel)).split(/\r?\n/); - const crc = lines[1]?.trim(); - if (!/^\d+$/.test(crc || '')) return null; - const kv = {}; - for (const line of lines.slice(2)) { - const m = line.trim().match(/^(\S+)\s+"([^"]*)"/); - if (m) kv[m[1].toLowerCase()] = m[2]; - } - return { crc: Number(crc), file: rel, type: kv.propertytype || '', name: kv.propertyname || '', kv }; -} - -function propertyRegistries(index) { - const runtime = new Map(); - const outside = new Map(); - let collisions = 0; - for (const rel of index.files) { - if (!PROPERTY_EXTENSIONS.includes(path.extname(rel).toLowerCase())) continue; - const prop = parseProperty(index, rel); - if (!prop) continue; - // PropertyRegistry::scan_list only accepts the top-level Property/ directory. - const target = rel.toLowerCase().startsWith('property/') ? runtime : outside; - if (target.has(prop.crc)) { if (target === runtime) collisions += 1; continue; } - target.set(prop.crc, prop); - } - return { runtime, outside, collisions }; -} - -function parseObjects(text) { - const objects = []; - for (const match of text.matchAll(/Start Object\d+\r?\n([^]*?)\r?\nEnd Object/g)) { - const lines = match[1].split(/\r?\n/).map(s => s.trim()).filter(Boolean); - if (lines.length < 2 || !/^\d+$/.test(lines[1])) continue; - objects.push({ crc: Number(lines[1]), position: lines[0] }); - } - return objects; -} - -function parseTextureSet(text) { - const entries = []; - for (const match of text.matchAll(/Start Texture(\d+)\r?\n([^]*?)\r?\nEnd Texture/g)) { - const file = match[2].match(/"([^"]*)"/)?.[1] ?? ''; - entries.push({ index: Number(match[1]), file }); - } - return entries; -} - -function parseNpcList(index) { - const result = new Map(); - for (const candidate of ['root/npclist.txt', 'npclist.txt', 'locale/npclist.txt']) { - const rel = index.rel(candidate); - if (!rel) continue; - for (const line of read(index.abs(rel)).split(/\r?\n/)) { - const parts = line.trim().split(/\s+/); - if (parts.length >= 2 && /^\d+$/.test(parts[0])) result.set(Number(parts[0]), parts[1]); - } - return { file: rel, entries: result }; - } - return { file: '', entries: result }; -} - -function auditMap(index, registries, key, source, missing) { - const note = (kind, ref, reason, extra = {}) => missing.push({ scope: `map:${key}`, kind, ref, reason, ...extra }); - const rootRel = index.rel(path.posix.join(key, 'setting.txt')); - const map = { key, source, exists: false, setting: '', setting_values: {}, tiles: [], texture_set: null, - environment: null, regen: null, object_count: 0, static_spt: [], static_gr2: [], effects: [], ambience: [], - unresolved_properties: [], property_count: 0 }; - if (!rootRel) { - note('map_setting', `${key}/Setting.txt`, 'map directory or Setting.txt not found under the asset root'); - return map; - } - map.exists = true; - map.setting = rootRel; - const dirRel = path.posix.dirname(rootRel); - const setting = parseKeyValues(read(index.abs(rootRel))); - const [sizeX, sizeY] = (setting.mapsize || '').split(/\s+/).map(Number); - map.setting_values = { map_size: [sizeX || 0, sizeY || 0], base_position: (setting.baseposition || '').split(/\s+/).map(Number), - cell_scale: Number(setting.cellscale || 0), texture_set: setting.textureset || '', environment: setting.environment || '' }; - if (!(sizeX > 0 && sizeY > 0)) note('map_setting', rootRel, 'MapSize is missing or invalid'); - - // Tiles: the runtime loads / for every MapSize cell (m2coord::tile_dir). - const objects = []; - const ambienceObjects = []; - for (let tx = 0; tx < (sizeX || 0); tx += 1) { - for (let ty = 0; ty < (sizeY || 0); ty += 1) { - const tile = String(tx * 1000 + ty).padStart(6, '0'); - const files = Object.fromEntries(TILE_FILES.map(name => [name, Boolean(index.rel(`${dirRel}/${tile}/${name}`))])); - map.tiles.push({ tile, files }); - for (const [name, present] of Object.entries(files)) if (!present) note('tile_file', `${dirRel}/${tile}/${name}`, 'tile file missing'); - const area = index.rel(`${dirRel}/${tile}/areadata.txt`); - if (area) objects.push(...parseObjects(read(index.abs(area))).map(o => ({ ...o, tile }))); - const ambience = index.rel(`${dirRel}/${tile}/areaambiencedata.txt`); - if (ambience) ambienceObjects.push(...parseObjects(read(index.abs(ambience))).map(o => ({ ...o, tile }))); - } - } - map.object_count = objects.length; - map.ambience_object_count = ambienceObjects.length; - - // TextureSet: runtime reads /textureset/. - if (setting.textureset) { - const tsRel = index.rel(`textureset/${posix(setting.textureset).toLowerCase()}`); - map.texture_set = { ref: setting.textureset, file: tsRel, textures: [] }; - if (!tsRel) note('texture_set', setting.textureset, 'TextureSet file not found under textureset/'); - else { - map.texture_set.textures = parseTextureSet(read(index.abs(tsRel))).map(entry => { - const real = entry.file ? index.resolve(entry.file) : ''; - if (entry.file && !real) note('terrain_texture', entry.file, 'TextureSet entry does not resolve'); - return { ...entry, resolved: real }; - }); - if (map.texture_set.textures.length === 0) note('texture_set', tsRel, 'TextureSet has no texture entries'); - } - } else note('texture_set', rootRel, 'Setting.txt has no TextureSet'); - - if (setting.environment) { - const real = index.resolve(`d:/ymir work/environment/${setting.environment}`); - map.environment = { ref: setting.environment, resolved: real }; - if (!real) note('environment', setting.environment, 'environment .msenv does not resolve'); - } - - const regenRel = index.rel(`${dirRel}/regen.txt`); - const regenEntries = regenRel ? read(index.abs(regenRel)).split(/\r?\n/).filter(l => l.trim() && !l.trim().startsWith('//')).length : 0; - map.regen = { file: regenRel, entries: regenEntries, - note: regenEntries === 0 ? 'no client-side spawn data; server spawn membership is unknown' : 'client regen.txt is not proof of live server spawns' }; - - const manifestRel = index.rel('TreeGeometry/manifest.json'); - const manifest = manifestRel ? JSON.parse(fs.readFileSync(index.abs(manifestRel), 'utf8')) : { trees: {} }; - const nativeBySource = new Map(Object.values(manifest.trees || {}).map(item => [index.resolve(item.source) || AssetIndex.normalize(item.source), item])); - - const groups = new Map(); - const counts = new Map(); - for (const o of [...objects, ...ambienceObjects]) counts.set(o.crc, (counts.get(o.crc) || 0) + 1); - map.property_count = counts.size; - for (const [crc, instances] of counts) { - const prop = registries.runtime.get(crc); - if (!prop) { - const elsewhere = registries.outside.get(crc); - const item = { property_id: crc, instances, found_outside_runtime_scope: elsewhere ? elsewhere.file : '' }; - map.unresolved_properties.push(item); - note('property', String(crc), elsewhere ? 'CRC only exists outside Property/, which the runtime registry does not scan' - : 'CRC not found in any property file', { instances }); - continue; - } - const base = { property_id: crc, instances, property_file: prop.file, property_type: prop.type, name: prop.name }; - const type = prop.type.toLowerCase(); - if (type === 'tree') { - const ref = prop.kv.treefile || ''; - const spt = ref ? index.resolve(ref) : ''; - const native = spt ? nativeBySource.get(spt) : undefined; - const glb = native ? index.rel(`TreeGeometry/${native.glb}`) : ''; - const item = { ...base, treefile: ref, spt, native_glb: glb, source_sha256: native?.source_sha256 || null }; - map.static_spt.push(item); - if (!spt) note('tree_spt', ref || prop.file, 'treefile does not resolve', { instances }); - else if (!glb) note('tree_native_geometry', spt, 'no extracted TreeGeometry entry for this SPT', { instances }); - } else if (type === 'building' || type === 'dungeonblock') { - const ref = prop.kv[type === 'building' ? 'buildingfile' : 'dungeonblockfile'] || ''; - const gr2 = ref ? index.resolve(ref) : ''; - map.static_gr2.push({ ...base, model: ref, gr2, sha256: gr2 ? sha256(index.abs(gr2)) : null }); - if (!gr2) note('building_gr2', ref || prop.file, 'model file does not resolve', { instances }); - } else if (type === 'effect') { - const ref = prop.kv.effectfile || ''; - const mse = ref ? index.resolve(ref) : ''; - // Metin2World skips Effect objects for static rendering; still audit the file. - map.effects.push({ ...base, effectfile: ref, mse, rendered_by_world_loader: false }); - if (!mse) note('effect_mse', ref || prop.file, 'effect file does not resolve', { instances }); - } else if (type === 'ambience') { - const refs = (prop.kv.ambiencesoundvector || '').split(/[,;]/).map(s => s.trim()).filter(Boolean); - const sounds = refs.map(ref => ({ ref, resolved: index.resolve(ref) || index.rel(ref) || index.rel(`sound/${ref}`) })); - map.ambience.push({ ...base, sounds }); - for (const s of sounds) if (!s.resolved) note('ambience_sound', s.ref, 'ambience sound does not resolve', { instances }); - } else { - map.unresolved_properties.push({ ...base, unsupported_type: true }); - note('property_type', prop.file, `unsupported property type "${prop.type}"`, { instances }); - } - } - map.static_spt_instances = map.static_spt.reduce((n, item) => n + item.instances, 0); - map.static_gr2_instances = map.static_gr2.reduce((n, item) => n + item.instances, 0); - return map; -} - -// Mirrors MobView._find_dir + the npclist stem segment fallback (bear_brown -> bear). -function findMobDir(index, code) { - const segs = code.split('_'); - for (let cut = segs.length; cut > 0; cut -= 1) { - const folder = segs.slice(0, cut).join('_'); - const rels = ['ymir work/monster', 'ymir work/monster2', 'ymir work/npc', 'ymir work/npc2', - 'Monster/ymir work/monster', 'Monster/ymir work/monster2', 'NPC/ymir work/npc', 'NPC/ymir work/npc2'].map(r => `${r}/${folder}`); - for (const rel of rels) if (index.hasDir(rel)) return rel; - for (const sub of index.topDirs) for (const rel of rels) if (index.hasDir(`${sub}/${rel}`)) return `${sub}/${rel}`; - } - return ''; -} - -function auditMob(index, race, code, missing, explained) { - const scope = `race:${race}`; - const note = (kind, ref, reason) => missing.push({ scope, kind, ref, reason }); - const record = { race, code, candidate_only: true, directory: '', files: {}, msm: null, textures: [], motions: [], states: {} }; - if (!code) { note('npclist', String(race), 'race is not listed in npclist'); return record; } - const dir = findMobDir(index, code); - record.directory = dir; - if (!dir) { note('mob_directory', code, 'no monster/npc directory for this code'); return record; } - const inDir = name => index.rel(`${dir}/${name}`); - const gr2 = inDir(`${code}.gr2`) || inDir(`${path.posix.basename(dir)}.gr2`); - const msmRel = inDir(`${code}.msm`); - record.files = { gr2, msm: msmRel, motlist: inDir('motlist.txt') }; - record.source_sha256 = gr2 ? sha256(index.abs(gr2)) : null; - if (!gr2) note('mob_gr2', `${dir}/${code}.gr2`, 'base model GR2 missing (dynamic skinned model)'); - if (msmRel) { - const base = read(index.abs(msmRel)).match(/BaseModelFileName\s+"([^"]+)"/i)?.[1] || ''; - const resolved = base ? index.resolve(base) : ''; - record.msm = { file: msmRel, base_model: base, resolved, matches_runtime_gr2: Boolean(resolved && gr2 && resolved.toLowerCase() === gr2.toLowerCase()) }; - if (base && !resolved) note('msm_base_model', base, 'MSM BaseModelFileName does not resolve'); - } else note('mob_msm', `${dir}/${code}.msm`, 'MSM missing'); - const dirPrefix = `${dir.toLowerCase()}/`; - record.textures = index.files.filter(f => f.toLowerCase().startsWith(dirPrefix) && !f.slice(dirPrefix.length).includes('/') && f.toLowerCase().endsWith('.dds')); - if (record.textures.length === 0) note('mob_texture', dir, 'no .dds texture next to the model'); - - const motions = new Map(); - if (record.files.motlist) { - for (const line of read(index.abs(record.files.motlist)).split(/\r?\n/)) { - const parts = line.trim().split(/\s+/); - if (parts.length < 3 || !parts[2].toLowerCase().endsWith('.msa')) continue; - const file = inDir(parts[2]); - record.motions.push({ group: parts[0], name: parts[1], file: parts[2], exists: Boolean(file), weight: Number(parts[3] || 0) }); - if (!file) note('motion_file', `${dir}/${parts[2]}`, `motlist ${parts[1]} references a missing .msa`); - else if (!motions.has(parts[1])) motions.set(parts[1], file); - } - } else note('motlist', `${dir}/motlist.txt`, 'motlist missing'); - - for (const [state, names] of Object.entries(STATE_MOTIONS)) { - let entry = null; - for (const name of names) { - if (motions.has(name)) { entry = { motion: name, file: motions.get(name), fallback_reason: name === names[0] ? '' : `client_alias:${name}` }; break; } - } - if (!entry) { - for (const name of names) { - const file = inDir(`${name.toLowerCase()}.msa`); - if (file) { entry = { motion: name, file, fallback_reason: `motlist_missing:${path.posix.basename(file)}` }; break; } - } - } - if (!entry) { - entry = { motion: '', file: '', fallback_reason: 'reference_keep_current_motion' }; - if (REFERENCE_KEEP_CURRENT_STATES.includes(state)) { - explained.push({ scope, kind: 'motion', ref: state, reason: `no ${names.join('/')} motion; reference SetLoopMotion keeps the current loop`, - needs_live_confirmation: 'a moving server instance of this race keeps its idle loop while its position changes' }); - } else note('motion', `${code}:${state}`, `no ${names.join('/')} motion for a required state`); - } - record.states[state] = entry; - } - return record; -} - -function mapsFromConfig(configPath) { - if (!fs.existsSync(configPath)) throw new Blocked(`config not found: ${configPath}`); - let config; - try { config = JSON.parse(fs.readFileSync(configPath, 'utf8')); } catch (error) { throw new Blocked(`config is not valid JSON: ${error.message}`); } - const mapKey = String(config.map_key || '').trim(); - if (!mapKey) throw new Blocked('config map_key is empty; resolve it from the server/map fixture'); - if (mapKey.startsWith('/') || mapKey.includes('\\') || mapKey.split('/').includes('..')) throw new Blocked('config map_key must be a relative asset directory'); - return { maps: [mapKey], scenario_id: String(config.scenario_id || '') }; -} - -function main() { - const options = parseArgs(process.argv.slice(2)); - const assets = path.resolve(options.assets); - if (!fs.existsSync(assets)) throw new Blocked(`asset root not found: ${assets}`); - let mapSource = 'default'; - let maps = DEFAULT_MAPS; - let scenarioId = ''; - if (options.config) { - const fromConfig = mapsFromConfig(options.config); - maps = fromConfig.maps; - scenarioId = fromConfig.scenario_id; - mapSource = 'config'; - } else if (options.maps) { - maps = options.maps.split(',').map(s => s.trim()).filter(Boolean); - mapSource = 'argument'; - } - const index = new AssetIndex(assets); - const registries = propertyRegistries(index); - const missing = []; - const explained = []; - const mapReports = maps.map(key => auditMap(index, registries, key, mapSource, missing)); - const npc = parseNpcList(index); - if (!npc.file) missing.push({ scope: 'global', kind: 'npclist', ref: 'root/npclist.txt', reason: 'npclist not found' }); - const races = options.races ? options.races.split(',').map(Number).filter(Number.isInteger) - : [...Array(15)].map((_, i) => 2301 + i).filter(race => npc.entries.has(race)); - if (races.length === 0) missing.push({ scope: 'global', kind: 'tree_monster_candidates', ref: '2301-2315', reason: 'no candidate races found' }); - const mobs = races.map(race => auditMob(index, race, npc.entries.get(race) || '', missing, explained)); - - const report = { - schema_version: 2, suite: 'playable-map-assets', generated_at: new Date().toISOString(), - assets_root: assets, map_source: mapSource, scenario_id: scenarioId, npclist: npc.file, - property_registry: { runtime_properties: registries.runtime.size, outside_runtime_scope: registries.outside.size, crc_collisions: registries.collisions }, - maps: mapReports, tree_monster_candidates: mobs, - missing, explained, - status: missing.length === 0 ? 'PASS' : 'FAIL', - caveat: 'Static map AreaData and local resources only; tree monsters are candidates, not proof of live server spawns. Visual correctness requires the render tests and manual sign-off.', - }; - const output = path.resolve(options.output); - fs.mkdirSync(path.dirname(output), { recursive: true }); - fs.writeFileSync(output, `${JSON.stringify(report, null, 2)}\n`); - console.log(`MAP_ASSETS ${JSON.stringify({ status: report.status, output, maps: mapReports.map(m => ({ key: m.key, exists: m.exists, objects: m.object_count, - spt: m.static_spt_instances || 0, gr2: m.static_gr2_instances || 0, unresolved: m.unresolved_properties.length })), - tree_monsters: mobs.length, missing: missing.length, explained: explained.length })}`); - for (const item of missing) console.error(`MISSING ${item.scope} ${item.kind} ${item.ref}: ${item.reason}`); - process.exitCode = missing.length === 0 ? 0 : 1; -} - -try { - main(); -} catch (error) { - if (error instanceof Blocked) { - console.error(`MAP_ASSETS BLOCKED: ${error.message}`); - process.exitCode = 2; - } else { - console.error(`MAP_ASSETS ERROR: ${error.stack || error}`); - process.exitCode = 1; - } -} diff --git a/script/audit_playable_maps_test.mjs b/script/audit_playable_maps_test.mjs deleted file mode 100644 index 0e703ab3..00000000 --- a/script/audit_playable_maps_test.mjs +++ /dev/null @@ -1,103 +0,0 @@ -#!/usr/bin/env node -/* MAP-01 audit regression on a throw-away asset tree. Proves that unresolved - * property CRCs, missing model/texture/motion files and an empty config map_key - * are reported (exit 1 / 2) instead of passing, and that a resource-level - * RUN/WALK gap is only "explained" by the reference keep-current-motion rule. */ -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; - -const script = path.join(path.dirname(new URL(import.meta.url).pathname), 'audit_playable_maps.mjs'); -const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mt-map-audit.')); -let failures = 0; -const check = (ok, label) => { if (ok) console.log(`ok - ${label}`); else { failures += 1; console.error(`not ok - ${label}`); } }; - -const write = (rel, text) => { const file = path.join(root, rel); fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, text); }; -const object = (i, crc) => `Start Object${String(i).padStart(3, '0')}\n 100.0 -100.0 0.0\n ${crc}\n 0.0#0.0#0.0\n 0\nEnd Object\n`; -const prop = (crc, type, key, value) => `YPRT\n${crc}\n${key}\t\t"${value}"\npropertyname\t\t"p${crc}"\npropertytype\t\t"${type}"\n`; -const motlist = names => names.map(([name, file]) => `GENERAL ${name} ${file} 50`).join('\n') + '\n'; - -function buildFixture({ broken }) { - fs.rmSync(root, { recursive: true, force: true }); - write('mapa/setting.txt', 'ScriptType\tMapSetting\nCellScale\t200\nMapSize\t1\t1\nBasePosition\t0\t0\nTextureSet\ttextureset\\mapa.txt\nEnvironment\ta.msenv\n'); - write('mapa/000000/height.raw', 'x'); - write('mapa/000000/tile.raw', 'x'); - write('mapa/000000/attr.atr', 'x'); - const objects = [object(0, 11), object(1, 22), object(2, 33)]; - if (broken) objects.push(object(3, 44), object(4, 55)); - write('mapa/000000/areadata.txt', `AreaDataFile\n\nObjectCount ${objects.length}\n${objects.join('')}`); - write('textureset/textureset/mapa.txt', 'TextureSet\n\nTextureCount 1\n\nStart Texture001\n "d:\\ymir work\\terrainmaps\\a\\field.dds"\n 5.0\nEnd Texture001\n'); - write('Terrain/ymir work/terrainmaps/a/field.dds', 'x'); - write('ETC/ymir work/environment/a.msenv', 'x'); - write('Property/property/t/tree.prt', prop(11, 'Tree', 'treefile', 'd:/ymir work/tree/a.spt')); - write('Tree/ymir work/tree/a.spt', 'spt'); - write('TreeGeometry/manifest.json', JSON.stringify({ trees: { h: { source: 'Tree/ymir work/tree/a.spt', glb: 'h.glb', source_sha256: 'h' } } })); - write('TreeGeometry/h.glb', 'glb'); - write('Property/property/b/house.prb', prop(22, 'Building', 'buildingfile', 'd:/ymir work/zone/house.gr2')); - if (!broken) write('Zone/ymir work/zone/house.gr2', 'gr2'); - write('Property/property/e/fx.pre', prop(33, 'Effect', 'effectfile', 'd:/ymir work/effect/fx.mse')); - write('Effect/ymir work/effect/fx.mse', 'mse'); - // 44 only exists outside Property/ (the runtime registry never sees it); 55 exists nowhere. - write('Zone/property/stray.prb', prop(44, 'Building', 'buildingfile', 'd:/ymir work/zone/house.gr2')); - write('root/npclist.txt', '2301\tent_a\n2302\tent_b\n'); - const mob = (code, names) => { - const dir = `monster2/ymir work/monster2/${code}`; - write(`${dir}/${code}.gr2`, 'gr2'); - write(`${dir}/${code}.dds`, 'dds'); - write(`${dir}/${code}.msm`, `BaseModelFileName "d:\\ymir work\\monster2\\${code}\\${code}.gr2"\n`); - write(`${dir}/motlist.txt`, motlist(names)); - for (const [, file] of names) write(`${dir}/${file}`, 'msa'); - }; - mob('ent_a', [['WAIT', '00.msa'], ['NORMAL_ATTACK', '20.msa'], ['FRONT_DAMAGE', '30.msa'], ['FRONT_DEAD', '31.msa']]); - const b = [['WAIT', '00.msa'], ['RUN', '10.msa'], ['NORMAL_ATTACK', '20.msa'], ['FRONT_DAMAGE', '30.msa']]; - if (!broken) b.push(['FRONT_DEAD', '31.msa']); - mob('ent_b', b); -} - -function run(args) { - const output = path.join(root, 'out', 'map-assets.json'); - const result = spawnSync(process.execPath, [script, '--assets', root, '--output', output, ...args], { encoding: 'utf8' }); - const report = fs.existsSync(output) ? JSON.parse(fs.readFileSync(output, 'utf8')) : null; - return { code: result.status, report, stderr: result.stderr }; -} - -try { - buildFixture({ broken: false }); - let r = run(['--maps', 'mapa']); - check(r.code === 0 && r.report?.status === 'PASS', `complete fixture passes (exit ${r.code}) ${r.stderr}`); - check(r.report?.explained.some(e => e.scope === 'race:2301' && e.ref === 'run'), 'missing RUN/WALK is explained by the reference rule'); - const map = r.report?.maps[0]; - check(map?.static_spt[0]?.native_glb === 'TreeGeometry/h.glb' && map?.static_gr2[0]?.gr2 === 'Zone/ymir work/zone/house.gr2', 'tree SPT and building GR2 resolve'); - check(map?.texture_set?.textures[0]?.resolved === 'Terrain/ymir work/terrainmaps/a/field.dds', 'TextureSet entries resolve through ymir work'); - check(map?.effects[0]?.mse === 'Effect/ymir work/effect/fx.mse', 'effect property resolves'); - check(r.report?.tree_monster_candidates.every(m => m.candidate_only && m.msm?.matches_runtime_gr2), 'MSM base model matches runtime GR2 and races stay candidates'); - - buildFixture({ broken: true }); - r = run(['--maps', 'mapa']); - const kinds = new Set((r.report?.missing || []).map(m => `${m.kind}:${m.ref}`)); - check(r.code === 1 && r.report?.status === 'FAIL', `broken fixture fails (exit ${r.code})`); - check(kinds.has('building_gr2:d:/ymir work/zone/house.gr2'), 'missing building GR2 is listed'); - check(kinds.has('property:44') && r.report?.maps[0].unresolved_properties.some(p => p.property_id === 44 && p.found_outside_runtime_scope === 'Zone/property/stray.prb'), 'CRC outside Property/ is listed with its location'); - check(kinds.has('property:55'), 'unknown CRC is listed'); - check(kinds.has('motion:ent_b:dead'), 'missing death motion is not explained away'); - check(!kinds.has('motion:ent_a:run'), 'RUN/WALK gap is explained, not missing'); - - fs.rmSync(path.join(root, 'mapa', '000000', 'attr.atr')); - r = run(['--maps', 'mapa']); - check((r.report?.missing || []).some(m => m.kind === 'tile_file'), 'missing tile file is listed'); - - write('scenario.local.json', JSON.stringify({ scenario_id: 'x', map_key: '' })); - r = run(['--config', path.join(root, 'scenario.local.json')]); - check(r.code === 2 && /map_key is empty/.test(r.stderr), `empty config map_key is BLOCKED (exit ${r.code})`); - write('scenario.local.json', JSON.stringify({ scenario_id: 'x', map_key: '../mapa' })); - r = run(['--config', path.join(root, 'scenario.local.json')]); - check(r.code === 2, 'map_key escaping the asset root is BLOCKED'); - write('scenario.local.json', JSON.stringify({ scenario_id: 'x', map_key: 'mapa' })); - r = run(['--config', path.join(root, 'scenario.local.json')]); - check(r.report?.map_source === 'config' && r.report?.maps[0].key === 'mapa', 'config map_key is the audited map'); -} finally { - fs.rmSync(root, { recursive: true, force: true }); -} -console.log(`audit_playable_maps_test: failures=${failures}`); -process.exitCode = failures ? 1 : 0; diff --git a/script/beta_probe.sh b/script/beta_probe.sh deleted file mode 100755 index 333143be..00000000 --- a/script/beta_probe.sh +++ /dev/null @@ -1,104 +0,0 @@ -#!/usr/bin/env bash -# Run the exported native 40250 macOS client from an unrelated working directory. -# Credentials come only from MT_LIVE_LOGIN / MT_LIVE_PASSWORD in the environment. -set -euo pipefail -set +x -cd "$(dirname "$0")/.." -repo="$PWD" -app="${1:-}" -output="${2:-$repo/build/beta-probe-$(date +%Y%m%d-%H%M%S)-$$}" -if [ -z "$app" ] || [ ! -d "$app" ]; then echo 'BETA_PROBE: BLOCKED provide an exported .app' >&2; exit 2; fi -if [ -z "${MT_LIVE_HOST:-}" ] || [ -z "${MT_LIVE_LOGIN:-}" ] || [ -z "${MT_LIVE_PASSWORD:-}" ]; then - echo 'BETA_PROBE: BLOCKED MT_LIVE_HOST, MT_LIVE_LOGIN and MT_LIVE_PASSWORD are required' >&2 - exit 2 -fi -app="$(cd "$app" && pwd)" -if [ -e "$output" ] && [ -n "$(find "$output" -mindepth 1 -print -quit)" ]; then - echo 'BETA_PROBE: BLOCKED output directory is not empty' >&2 - exit 2 -fi -mkdir -p "$output" -output="$(cd "$output" && pwd)" -engine="$app/Contents/MacOS/mtgodot-poc" -client="$app/Contents/Resources/40250/Client" -if [ ! -x "$engine" ] || [ ! -s "$client/pack/Index" ] || ! codesign --verify --strict "$app" >/dev/null 2>&1; then - echo 'BETA_PROBE: BLOCKED bundle executable, 40250 pack or signature is invalid' >&2 - exit 2 -fi -bash script/playable_build_info.sh "$app" >"$output/candidate.json" -python3 - "$client" "$app/Contents/Resources/beta-build.json" "$output/candidate.json" <<'PY' -import hashlib, json, pathlib, sys -client, build_manifest, output = map(pathlib.Path, sys.argv[1:]) -digest = hashlib.sha256() -for path in sorted(p for p in client.rglob('*') if p.is_file()): - relative = path.relative_to(client).as_posix().encode() - digest.update(len(relative).to_bytes(4, 'big')) - digest.update(relative) - with path.open('rb') as stream: - for chunk in iter(lambda: stream.read(1024 * 1024), b''): - digest.update(chunk) -candidate = json.loads(output.read_text()) -candidate.update(json.loads(build_manifest.read_text())) -candidate['client_resources_sha256'] = digest.hexdigest() -output.write_text(json.dumps(candidate, indent=2) + '\n') -PY - -# Python owns exactly this child, enforces the wall-clock deadline, and writes only the process -# code and status to its report. The password is inherited by the child, never placed in argv. -python3 - "$engine" "$output" <<'PY' -import datetime, json, os, pathlib, platform, re, subprocess, sys, tempfile -engine, output = sys.argv[1:] -root = pathlib.Path(output) -env = os.environ.copy() -for key in ('MT_40250_CLIENT', 'MT_ASSETS', 'MT_ASSETS_ZIP', 'MT_PYTHON_STDLIB'): - env.pop(key, None) -env['MT_TEST_MODE'] = 'beta_probe' -env['MT_BETA_OUTPUT'] = output -env['MT_BETA_REQUIRE_BUNDLE'] = '1' -timed_out = False -with tempfile.TemporaryDirectory(prefix='mt-beta-cwd-') as cwd, (root / 'client.log').open('wb') as log: - proc = subprocess.Popen([engine], cwd=cwd, env=env, stdout=log, stderr=subprocess.STDOUT) - try: - code = proc.wait(timeout=180) - except subprocess.TimeoutExpired: - timed_out = True - proc.terminate() - try: - code = proc.wait(timeout=10) - except subprocess.TimeoutExpired: - proc.kill() - code = proc.wait() -client = None -try: - client = json.loads((root / 'client-report.json').read_text()) -except (OSError, ValueError): - pass -log_path = root / 'client.log' -log_text = log_path.read_text(errors='replace') -# Native trace output is outside the probe's control; keep account credentials out of artifacts. -for secret in (env.get('MT_LIVE_PASSWORD', ''), env.get('MT_LIVE_LOGIN', '')): - if secret: - log_text = log_text.replace(secret, '[REDACTED]') -log_path.write_text(log_text) -errors = [] -if timed_out: errors.append('wall_clock_timeout') -if code != 0: errors.append('client_exit_nonzero') -if not isinstance(client, dict) or client.get('route') != 'native_40250' or client.get('status') != 'PASS': - errors.append('native_client_report_missing_or_failed') -if not (root / 'game.png').is_file(): errors.append('screenshot_missing') -if re.search(r'SCRIPT ERROR|Parse Error|^ERROR:|leaked at exit|shaders of type .* were never freed', log_text, re.M): - errors.append('client_log_error') -if re.search(r'Cannot open property pack|CArea::LoadObject Property\(\d+\) Load ERROR', log_text): - errors.append('map_property_load_error') -candidate = json.loads((root / 'candidate.json').read_text()) -report = {'schema_version': 1, 'status': 'PASS' if not errors else 'FAIL', - 'tested_at_utc': datetime.datetime.now(datetime.timezone.utc).isoformat(), - 'device': {'macos': platform.mac_ver()[0], 'arch': platform.machine()}, - 'candidate': candidate, 'process_code': code, 'timed_out': timed_out, - 'cases': client.get('cases', []) if isinstance(client, dict) else [], - 'map': client.get('map', '') if isinstance(client, dict) else '', - 'errors': errors} -(root / 'report.json').write_text(json.dumps(report, indent=2) + '\n') -print('BETA_PROBE: ' + report['status'] + ' report=' + str(root / 'report.json')) -sys.exit(0 if not errors else 1) -PY diff --git a/script/build_macos_release_engine.sh b/script/build_macos_release_engine.sh deleted file mode 100644 index 120c11a7..00000000 --- a/script/build_macos_release_engine.sh +++ /dev/null @@ -1,22 +0,0 @@ -#!/usr/bin/env bash -# Reproducible Metal-only release template used by the particle exit checks. -# Does not replace Godot's installed templates. Pass the resulting executable -# to build-macos-client.sh using MT_MAC_ENGINE and the matching MT_MAC_ARCHES. -set -euo pipefail -cd "$(dirname "$0")/.." -repo_dir="$PWD" -source_dir="$repo_dir/build/godot-particle-diagnostic" -revision="a13da4feb8d8aefc283c3763d33a2f170a18d541" -engine_arch="${1:-arm64}" -case "$engine_arch" in arm64|x86_64) ;; *) echo "Expected arm64 or x86_64" >&2; exit 1 ;; esac -if [ ! -d "$source_dir" ]; then - git clone --depth 1 --branch 4.7.1-stable https://github.com/godotengine/godot.git "$source_dir" -fi -test "$(git -C "$source_dir" rev-parse HEAD)" = "$revision" -git -C "$source_dir" diff --exit-code -git -C "$source_dir" diff --cached --exit-code -cd "$source_dir" -scons platform=macos arch="$engine_arch" target=template_release \ - debug_symbols=yes optimize=speed lto=none vulkan=no -j8 -echo "Engine: $source_dir/bin/godot.macos.template_release.$engine_arch" -shasum -a 256 "bin/godot.macos.template_release.$engine_arch" diff --git a/script/forest_map_render_test.sh b/script/forest_map_render_test.sh deleted file mode 100755 index 49705c5e..00000000 --- a/script/forest_map_render_test.sh +++ /dev/null @@ -1,204 +0,0 @@ -#!/usr/bin/env bash -# MAP-02: run the packaged client in MT_TEST_MODE=forest_render and seal its report. -# -# - Offline: no server, no credentials. MT_ACCOUNT/MT_PASSWORD are removed from the -# child environment; nothing here reads or writes them. Do not add `set -x`. -# - Owns exactly one child PID; TERM, 10 s grace, KILL, then wait. Never pkills. -# - Maps come only from --maps or the config map_key (filled from the -# audit_playable_maps result); no map path is guessed here. -# - The client writes client-report.json/events/screenshots; this script writes -# report.json only after the real process exit and the full-log gate. -# - Metal screenshot sign-off stays a separate manual release item. -# -# exit: 0 PASS, 1 assert/exit-gate FAIL, 2 config/precondition BLOCKED, 124 wall-clock timeout. -set -euo pipefail -set +x - -cd "$(dirname "$0")/.." -repo="$PWD" -app_path="" -maps="" -config_path="" -viewpoints="" -races="" -output_dir="" -timeout_seconds=900 -required_arch="${MT_PLAYABLE_REQUIRED_ARCH:-arm64}" -kill_grace_seconds=10 -suite="forest_render" -source script/playable_process_cleanup.sh - -usage() { - cat <<'EOF' -用法: script/forest_map_render_test.sh --app APP (--maps KEYS | --config CONFIG) [选项] -选项: - --app APP 已签名的 arm64 包(build/export/mtgodot-poc.app);新测试模式必须重新构建后运行 - --maps K1[,K2] map-assets.json 里的 map_key,逗号分隔(优先于 --config 的 map_key) - --config FILE playable 配置;只读取 map_key/scenario_id - --viewpoints FILE 机位夹具(test/playable/forest-viewpoints..local.json);不给则只有自动候选 - --races R1[,R2] 覆盖夹具里的怪物 vnum;不给且夹具为空时放 12 个候选树怪 - --output DIR 本次运行目录,必须不存在或为空(默认 build/forest/run-<时间>-) - --timeout-seconds N 墙钟超时,默认 900;超时 TERM,10 秒后 KILL - --help 显示本帮助 -说明: - 自动候选机位、未确认机位、缺失传送点都会使对应用例 BLOCKED; - 即使自动用例 PASS,Metal 截图仍需人工签核(发布清单单独的 manual 项)。 -环境变量: - MT_PLAYABLE_REQUIRED_ARCH 包必须包含的架构,默认 arm64 -退出码: 0 PASS / 1 FAIL / 2 BLOCKED(配置或前置条件)/ 124 墙钟超时 -EOF -} - -while [ "$#" -gt 0 ]; do - case "$1" in - --app) app_path="${2:-}"; shift 2 ;; - --maps) maps="${2:-}"; shift 2 ;; - --config) config_path="${2:-}"; shift 2 ;; - --viewpoints) viewpoints="${2:-}"; shift 2 ;; - --races) races="${2:-}"; shift 2 ;; - --output) output_dir="${2:-}"; shift 2 ;; - --timeout-seconds) timeout_seconds="${2:-}"; shift 2 ;; - --help) usage; exit 0 ;; - *) echo "未知选项: $1" >&2; usage >&2; exit 2 ;; - esac -done - -output_ready=0 -blocked() { - echo "FOREST RENDER GATE: BLOCKED $*" >&2 - if [ "$output_ready" -eq 1 ]; then echo "BLOCKED $*" >>"$output_dir/gate.log"; fi - exit 2 -} - -abs_file() { echo "$(cd "$(dirname "$1")" && pwd)/$(basename "$1")"; } - -[ -n "$app_path" ] || blocked "需要 --app" -[[ "$timeout_seconds" =~ ^[1-9][0-9]*$ ]] || blocked "--timeout-seconds 必须是正整数" -engine="$app_path/Contents/MacOS/mtgodot-poc" -[ -x "$engine" ] || blocked "找不到可执行包:$app_path" -if [ -n "$config_path" ]; then - [ -f "$config_path" ] || blocked "配置不存在:$config_path" - config_path="$(abs_file "$config_path")" -fi -if [ -n "$viewpoints" ]; then - [ -f "$viewpoints" ] || blocked "机位夹具不存在:$viewpoints" - viewpoints="$(abs_file "$viewpoints")" -fi -if [ -n "$races" ] && ! [[ "$races" =~ ^[1-9][0-9]*(,[1-9][0-9]*)*$ ]]; then - blocked "--races 必须是逗号分隔的正整数 vnum" -fi - -# Resolve the map list exactly as the client does (MT_FOREST_MAPS, else config map_key). -map_list="$(node -e ' -const fs = require("node:fs"); -const [maps, config] = process.argv.slice(1); -let keys = maps.split(",").map((s) => s.trim()).filter(Boolean); -if (keys.length === 0 && config) { - try { const key = String(JSON.parse(fs.readFileSync(config, "utf8")).map_key ?? "").trim(); if (key) keys = [key]; } catch {} -} -process.stdout.write(keys.join(",")); -' "$maps" "$config_path")" -[ -n "$map_list" ] || blocked "没有地图:给 --maps,或在配置里填 map-assets.json 审计出的 map_key" - -if [ -z "$output_dir" ]; then - output_dir="$repo/build/forest/run-$(date +%Y%m%d-%H%M%S)-$$" -fi -if [ -e "$output_dir" ] && [ -n "$(find "$output_dir" -mindepth 1 -maxdepth 1 -print -quit)" ]; then - blocked "输出目录已有内容,拒绝复用历史报告:$output_dir" -fi -mkdir -p "$output_dir" -output_dir="$(cd "$output_dir" && pwd)" -output_ready=1 - -if ! codesign --verify --strict "$app_path" >/dev/null 2>&1; then - blocked "签名校验失败:codesign --verify --strict $app_path" -fi -archs="$(lipo -archs "$engine" 2>/dev/null || true)" -case " $archs " in - *" $required_arch "*) ;; - *) blocked "包架构 [$archs] 不含 $required_arch" ;; -esac - -run_id="$(basename "$output_dir")-$(od -An -N4 -tx4 /dev/urandom | tr -d ' ')" -log="$output_dir/client.log" -client_report="$output_dir/client-report.json" -final_report="$output_dir/report.json" -events="$output_dir/events.jsonl" - -# Same formula as forest_map_render_test.gd required_case_ids(); the validator -# fails the run if the client declares a different list. -node -e ' -const [file, suite, maps] = process.argv.slice(1); -const cases = ["MAP-FOREST-CONFIG"]; -for (const key of maps.split(",")) { - const id = key.split("/").pop(); - cases.push(`MAP-FOREST-LOAD-${id}`); - for (const kind of ["flat", "slope", "dense", "warp"]) cases.push(`MAP-FOREST-VP-${id}-${kind}`); - cases.push(`MAP-FOREST-MOTION-${id}`); -} -require("node:fs").writeFileSync(file, JSON.stringify({ schema_version: 1, suite, cases }, null, 2) + "\n"); -' "$output_dir/required-cases.json" "$suite" "$map_list" - -bash script/playable_build_info.sh "$app_path" >"$output_dir/build.json" -build_field() { node -e 'process.stdout.write(String(JSON.parse(require("node:fs").readFileSync(process.argv[1],"utf8"))[process.argv[2]]))' "$output_dir/build.json" "$1"; } - -started_ms="$(($(date +%s) * 1000))" -set +e -# No credentials reach this child, so its log needs no redactor. -env -u MT_ASSETS -u MT_ACCOUNT -u MT_PASSWORD \ - MT_TEST_MODE=forest_render \ - MT_FOREST_MAPS="$map_list" \ - MT_FOREST_VIEWPOINTS="$viewpoints" \ - MT_FOREST_RACES="$races" \ - MT_FOREST_TIMEOUT_SECONDS="$timeout_seconds" \ - MT_PLAYABLE_CONFIG="$config_path" \ - MT_PLAYABLE_RUN_ID="$run_id" \ - MT_TEST_REPORT="$client_report" \ - MT_TEST_EVENTS="$events" \ - MT_TEST_OUTPUT="$output_dir" \ - MT_BUILD_ENGINE_SHA256="$(build_field engine_sha256)" \ - MT_BUILD_EXTENSION_SHA256="$(build_field extension_sha256)" \ - MT_BUILD_PCK_SHA256="$(build_field pck_sha256)" \ - MT_BUILD_ARCH="$(build_field arch)" \ - "$engine" >"$log" 2>&1 /dev/null; do - if [ "$SECONDS" -ge "$timeout_seconds" ]; then - timed_out=1 - kill -TERM "$child_pid" 2>/dev/null - grace=0 - while kill -0 "$child_pid" 2>/dev/null && [ "$grace" -lt "$kill_grace_seconds" ]; do - sleep 1 - grace=$((grace + 1)) - done - killed=0 - if kill -0 "$child_pid" 2>/dev/null; then kill -KILL "$child_pid" 2>/dev/null; killed=1; fi - echo "TIMEOUT term_grace_s=$grace killed=$killed" >>"$output_dir/gate.log" - break - fi - sleep 1 -done -wait "$child_pid" -process_code=$? -child_pid="" - -node script/validate_playable_report.mjs --report "$client_report" --output "$final_report" \ - --log "$log" --process-code "$process_code" --timed-out "$timed_out" --run-id "$run_id" \ - --suite "$suite" --required-cases "$output_dir/required-cases.json" --build "$output_dir/build.json" \ - --events "$events" --started-ms "$started_ms" --require-pass -gate_status=$? -set -e - -echo "FOREST RENDER GATE: Metal 截图人工签核仍是单独的 manual 项($output_dir/forest-*.png + forest-map-evidence.json)" -if [ "$timed_out" -eq 1 ]; then - echo "FOREST RENDER GATE: TIMEOUT raw_exit=$process_code report=$final_report log=$log" >&2 - exit 124 -fi -case "$gate_status" in - 0) echo "FOREST RENDER GATE: PASS run_id=$run_id report=$final_report" ;; - 2) echo "FOREST RENDER GATE: BLOCKED raw_exit=$process_code report=$final_report" >&2 ;; - *) echo "FOREST RENDER GATE: FAIL raw_exit=$process_code report=$final_report log=$log" >&2; gate_status=1 ;; -esac -exit "$gate_status" diff --git a/script/live_smoke_test.sh b/script/live_smoke_test.sh deleted file mode 100755 index 19b2346e..00000000 --- a/script/live_smoke_test.sh +++ /dev/null @@ -1,172 +0,0 @@ -#!/usr/bin/env bash -# 真实 40250 服务器端到端冒烟测试。 -# -# 用法: -# ./script/live_smoke_test.sh -# ./script/live_smoke_test.sh --no-build -# ./script/live_smoke_test.sh --no-mutations -# ./script/live_smoke_test.sh --no-reconnect -# MT_SMOKE_PHASE_COMMAND=/logout ./script/live_smoke_test.sh --no-build --no-reconnect -# -# 账号和密码优先从环境变量读取;未提供时安全地交互输入,不写入脚本。 -# 服务器地址与客户端一样经 project/net/serverinfo.gd 解析,不在脚本里写死。 -# 不要在本脚本中启用 set -x。 -set -euo pipefail -set +x - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -REPO="$(cd "$SCRIPT_DIR/.." && pwd)" -APP="${MT_SMOKE_APP:-$REPO/build/export/mtgodot-poc.app}" -EXECUTABLE="$APP/Contents/MacOS/mtgodot-poc" -ARTIFACT_DIR="${MT_SMOKE_OUTPUT:-$REPO/build/live-smoke}" -LOG="$ARTIFACT_DIR/live-smoke.log" -REPORT="$ARTIFACT_DIR/live-smoke-report.json" -CONFIG_FILE="${MT_SMOKE_CONFIG:-$REPO/.env.live-smoke.local}" - -BUILD=1 -MUTATIONS=1 -RECONNECT=1 - -usage() { - cat <<'EOF' -用法: ./script/live_smoke_test.sh [选项] - -选项: - --no-build 使用现有 APP,不重新打包 - --no-mutations 只做登录、进游戏和数据读取,不发送移动/聊天/重连包 - --no-reconnect 做移动和聊天,但跳过重连 - --reconnect-only 只测试登录/主动重连,不发送移动、聊天或施法 - --help 显示帮助 - -环境变量: - MT_ACCOUNT / MT_PASSWORD 可预先提供账号密码,未提供时交互输入 - MT_CHAR_SLOT=N 指定角色槽位;不设置时自动选择第一个角色 - MT_SMOKE_PHASE_COMMAND=/logout 进场后发送服务器阶段命令并验证 peer-close 后回 LoginPhase - MT_GODOT 解析 serverinfo 用的 godot,默认 godot -EOF -} - -while [ "$#" -gt 0 ]; do - case "$1" in - --no-build) BUILD=0 ;; - --no-mutations) MUTATIONS=0; RECONNECT=0 ;; - --no-reconnect) RECONNECT=0 ;; - --reconnect-only) MUTATIONS=0; RECONNECT=1 ;; - --help) usage; exit 0 ;; - *) echo "未知选项: $1" >&2; usage >&2; exit 2 ;; - esac - shift -done - -cd "$REPO" -mkdir -p "$ARTIFACT_DIR" - -if [ -f "$CONFIG_FILE" ]; then - # shellcheck disable=SC1090 - . "$CONFIG_FILE" -fi - -if [ -z "${MT_ACCOUNT:-}" ]; then - read -r -s -p "账号: " MT_ACCOUNT - echo -fi -if [ -z "${MT_PASSWORD:-}" ]; then - read -r -s -p "密码: " MT_PASSWORD - echo -fi - -if [ -z "${MT_ACCOUNT:-}" ] || [ -z "${MT_PASSWORD:-}" ]; then - echo "账号和密码不能为空" >&2 - exit 2 -fi - -if [ "$BUILD" -eq 1 ]; then - echo "== build release ==" - ./build-macos-client.sh release -fi - -if [ ! -x "$EXECUTABLE" ]; then - echo "找不到可执行 APP:$EXECUTABLE" >&2 - echo "请先运行 ./build-macos-client.sh release" >&2 - exit 2 -fi - -echo "== check server ports ==" -# 登录页默认 server 0 / 第一个频道;与 AppFlow 同一 ServerInfo 解析。 -ADDRESS_LINE="$("${MT_GODOT:-godot}" --headless --path project --script print_server_address.gd 2>/dev/null | grep '^SERVER_ADDRESS ' || true)" -if [ -z "$ADDRESS_LINE" ]; then - echo "无法从 serverinfo 解析服务器地址" >&2 - exit 2 -fi -read -r AUTH_HOST AUTH_PORT GAME_HOST GAME_PORT <<<"$(node -e ' -const a = JSON.parse(process.argv[1].slice("SERVER_ADDRESS ".length)); -process.stdout.write([a.auth_host, a.auth_port, a.game_host, a.game_port].join(" ")); -' "$ADDRESS_LINE")" -for endpoint in "$AUTH_HOST:$AUTH_PORT" "$GAME_HOST:$GAME_PORT"; do - if ! nc -G 3 -z "${endpoint%:*}" "${endpoint##*:}" >/dev/null 2>&1; then - echo "服务器端口不可达:$endpoint" >&2 - exit 3 - fi -done - -if pgrep -x mtgodot-poc >/dev/null; then - echo "已有客户端运行,请先关闭后再执行独立登录测试。" >&2 - exit 2 -fi - -export MT_PROTOCOL=classic -export MT_AUTOLOGIN=1 -export MT_TEST_MODE=smoke -export MT_ACCOUNT -export MT_PASSWORD -export MT_SMOKE_MUTATIONS="$MUTATIONS" -export MT_SMOKE_RECONNECT="$RECONNECT" -export MT_TEST_REPORT="$REPORT" -rm -f "$REPORT" - -echo "== run live smoke ==" -echo "log: $LOG" -echo "report: $REPORT" -set +e -# 日志落盘前脱敏;PIPESTATUS[0] 仍是 APP 的真实退出码。 -env -u MT_ASSETS "$EXECUTABLE" 2>&1 | node script/redact_stream.mjs | tee "$LOG" -APP_STATUS=${PIPESTATUS[0]} -set -e - -if [ ! -f "$REPORT" ]; then - echo "未生成本次测试报告,APP 可能在测试状态机启动前崩溃。" >&2 - echo "LIVE_SMOKE RESULT: FAIL (missing report)" >&2 - exit 4 -fi - -echo "== report ==" -if command -v jq >/dev/null 2>&1; then - REPORT_STATUS=$(jq -r '.status // empty' "$REPORT") - jq -r '"status=" + .status, "duration=" + (.duration_seconds|tostring) + "s", ( .failures[]? | "failure=" + . )' "$REPORT" -else - REPORT_STATUS="" - if grep -q '"status"[[:space:]]*:[[:space:]]*"PASS"' "$REPORT"; then - REPORT_STATUS=PASS - fi - grep -E '"status"|"failures"' "$REPORT" || true -fi - -if [ "$APP_STATUS" -ne 0 ]; then - echo "LIVE_SMOKE RESULT: FAIL (APP exit=$APP_STATUS)" >&2 - echo "最近的崩溃报告:" - ls -1t "$HOME/Library/Logs/DiagnosticReports/mtgodot-poc-"*.ips 2>/dev/null | head -n 3 || true - exit "$APP_STATUS" -fi - -# 与 package_render_test.sh / validate_playable_report.mjs 相同的完整日志门禁。 -if grep -nE 'SCRIPT ERROR|Parse Error|^ERROR:|leaked at exit|shaders of type .* were never freed' "$LOG"; then - echo "LIVE_SMOKE RESULT: FAIL (log exit gate)" >&2 - exit 1 -fi - -if [ "$REPORT_STATUS" != "PASS" ]; then - echo "LIVE_SMOKE RESULT: FAIL (report status=$REPORT_STATUS)" >&2 - exit 1 -fi - -echo "LIVE_SMOKE RESULT: PASS" diff --git a/script/package_render_test.sh b/script/package_render_test.sh deleted file mode 100644 index 25f70373..00000000 --- a/script/package_render_test.sh +++ /dev/null @@ -1,18 +0,0 @@ -#!/usr/bin/env bash -# A JSON PASS is insufficient: shutdown happens after the report is written. -# Keep the exported client's complete log and fail on renderer/RID leaks too. -set -euo pipefail -cd "$(dirname "$0")/.." -app_path="${MT_RENDER_APP:-$PWD/build/export/mtgodot-poc.app}" -output_dir="${MT_RENDER_OUTPUT:-$PWD/build/rendering/package-gate-$(date +%Y%m%d-%H%M%S)-$$}" -test -x "$app_path/Contents/MacOS/mtgodot-poc" -mkdir -p "$output_dir" -status=0 -env -u MT_ASSETS MT_TEST_MODE=render MT_RENDER_OUTPUT="$output_dir" \ - "$app_path/Contents/MacOS/mtgodot-poc" --quit-after 1800 >"$output_dir/client.log" 2>&1 || status=$? -if [ "$status" -ne 0 ] || ! grep -qE '^PKGRENDER: PASS$' "$output_dir/client.log" || \ - grep -qE 'SCRIPT ERROR|Parse Error|^ERROR:|leaked at exit|shaders of type .* were never freed' "$output_dir/client.log"; then - echo "FAIL: package load/exit gate (exit=$status): $output_dir/client.log" - exit 1 -fi -echo "PASS: package load and clean exit: $output_dir/client.log" diff --git a/script/playable_build_info.sh b/script/playable_build_info.sh deleted file mode 100755 index e218bdf6..00000000 --- a/script/playable_build_info.sh +++ /dev/null @@ -1,26 +0,0 @@ -#!/usr/bin/env bash -# Print the candidate package identity as JSON (engine/extension/pck sha256 + -# architectures). Shared by run_client_gate.sh and playable_test.sh so the -# per-run build.json and the release manifest are computed the same way. -set -euo pipefail - -if [ "${1:-}" = "--help" ] || [ "$#" -ne 1 ]; then - echo "用法: script/playable_build_info.sh APP" >&2 - [ "${1:-}" = "--help" ] && exit 0 - exit 2 -fi -app="$1" -engine="$app/Contents/MacOS/mtgodot-poc" -pck="$app/Contents/Resources/mtgodot-poc.pck" -dylib="$(find "$app/Contents/Frameworks" -maxdepth 1 -type f -name '*.dylib' 2>/dev/null | LC_ALL=C sort | head -n 1)" - -sha256_of() { - if [ -n "$1" ] && [ -f "$1" ]; then shasum -a 256 "$1" | awk '{print $1}'; else printf ''; fi -} -archs="" -if [ -f "$engine" ]; then archs="$(lipo -archs "$engine" 2>/dev/null | tr ' ' '+' || true)"; fi - -node -e ' -const [engine, extension, pck, arch] = process.argv.slice(1); -process.stdout.write(JSON.stringify({ engine_sha256: engine, extension_sha256: extension, pck_sha256: pck, arch }) + "\n"); -' "$(sha256_of "$engine")" "$(sha256_of "$dylib")" "$(sha256_of "$pck")" "$archs" diff --git a/script/playable_fault_proxy.mjs b/script/playable_fault_proxy.mjs deleted file mode 100644 index 1a7d2af3..00000000 --- a/script/playable_fault_proxy.mjs +++ /dev/null @@ -1,225 +0,0 @@ -#!/usr/bin/env node -/* STB-DISCONNECT-01 local transport-fault proxy for ONE test client connection. - * - * playable_fault_proxy.mjs --route LISTEN_PORT=UPSTREAM_HOST:UPSTREAM_PORT [--route ...] - * --events events.jsonl --log fault-proxy.jsonl [--run-id RUN_ID] [--poll-ms 200] - * - * - Listens on 127.0.0.1 only and forwards bytes to the upstream test server. The - * scenario's server.* points the client at these loopback ports; nothing else on - * the machine is touched: no routes, firewall rules, other clients or production. - * - Tails the client's events.jsonl and acts on each `fault_request` exactly once: - * close destroy the proxied connections ("connection closed") - * unreachable destroy them AND stop listening for payload.unreachable_seconds - * (capped at 20 s) so reconnect attempts are refused, then listen again - * - Every action is appended to --log as JSON (wall_ms, action, type, attempt, ...). - * The client, not this proxy, decides whether recovery happened. - * - Exits 0 on SIGTERM/SIGINT after closing its sockets. It never signals any process. - * - * Known limit: a server-side warp that hands out a direct game address bypasses the proxy. - * exit: 0 stopped, 2 usage error or a listen port could not be opened. */ -import fs from 'node:fs'; -import net from 'node:net'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const LISTEN_HOST = '127.0.0.1'; -const MAX_UNREACHABLE_S = 20; - -function usage(stream = process.stderr) { - stream.write('usage: playable_fault_proxy.mjs --route LISTEN_PORT=HOST:PORT [--route ...] --events events.jsonl --log fault-proxy.jsonl [--run-id ID] [--poll-ms 200]\n' - + ' listens on 127.0.0.1 only; acts on fault_request events (close | unreachable)\n'); -} - -const isPort = (value) => Number.isInteger(value) && value >= 1 && value <= 65535; - -export function parseArgs(argv) { - const options = { routes: [], poll_ms: 200 }; - for (let i = 0; i < argv.length; i += 1) { - const arg = argv[i]; - if (arg === '--help') { options.help = true; continue; } - const value = argv[i + 1]; - if (value === undefined) throw new Error(`missing value for ${arg}`); - i += 1; - if (arg === '--route') { - const match = /^(\d+)=([A-Za-z0-9.:-]+):(\d+)$/.exec(value); - if (!match) throw new Error(`bad --route ${value}`); - const route = { listen: Number(match[1]), host: match[2], port: Number(match[3]) }; - if (!isPort(route.listen) || !isPort(route.port)) throw new Error(`bad port in --route ${value}`); - if (['127.0.0.1', 'localhost', '::1'].includes(route.host) && route.port === route.listen) throw new Error(`route ${value} loops back to itself`); - if (options.routes.some((r) => r.listen === route.listen)) throw new Error(`duplicate listen port ${route.listen}`); - options.routes.push(route); - } else if (arg === '--events') options.events = value; - else if (arg === '--log') options.log = value; - else if (arg === '--run-id') options.run_id = value; - else if (arg === '--poll-ms') options.poll_ms = Number(value); - else throw new Error(`unknown option ${arg}`); - } - if (!options.help && (options.routes.length === 0 || !options.events || !options.log - || !Number.isInteger(options.poll_ms) || options.poll_ms < 20)) throw new Error('--route, --events and --log are required'); - return options; -} - -/** Incremental JSONL reader: returns complete new lines since the last call. */ -export function tailer(file) { - let offset = 0; - let partial = ''; - return () => { - let size; - try { size = fs.statSync(file).size; } catch { return []; } - if (size < offset) { offset = 0; partial = ''; } - if (size === offset) return []; - const fd = fs.openSync(file, 'r'); - const buffer = Buffer.alloc(size - offset); - fs.readSync(fd, buffer, 0, buffer.length, offset); - fs.closeSync(fd); - offset = size; - const text = partial + buffer.toString('utf8'); - const lines = text.split('\n'); - partial = lines.pop(); - return lines.filter((line) => line.trim() !== ''); - }; -} - -export class FaultProxy { - constructor(options) { - this.options = options; - this.servers = new Map(); - this.pairs = new Set(); - this.seen = new Set(); - this.unreachableTimer = null; - this.stopped = false; - this.read = tailer(options.events); - } - - log(action, detail = {}) { - fs.appendFileSync(this.options.log, `${JSON.stringify({ wall_ms: Date.now(), action, ...detail })}\n`); - } - - listen(route) { - return new Promise((resolve, reject) => { - const server = net.createServer((client) => this.accept(route, client)); - server.once('error', reject); - server.listen(route.listen, LISTEN_HOST, () => { - server.off('error', reject); - this.servers.set(route.listen, server); - resolve(); - }); - }); - } - - accept(route, client) { - const upstream = net.connect(route.port, route.host); - const pair = { client, upstream }; - this.pairs.add(pair); - const drop = () => { - if (!this.pairs.delete(pair)) return; - client.destroy(); - upstream.destroy(); - }; - client.on('error', drop).on('close', drop); - upstream.on('error', drop).on('close', drop); - client.pipe(upstream); - upstream.pipe(client); - this.log('accepted', { listen_port: route.listen, open: this.pairs.size }); - } - - dropAll() { - const count = this.pairs.size; - for (const pair of [...this.pairs]) { - this.pairs.delete(pair); - pair.client.destroy(); - pair.upstream.destroy(); - } - return count; - } - - /** Stops accepting, drops open connections (server.close waits for them), returns the drop count. */ - async closeListeners() { - const closing = [...this.servers.values()].map((server) => new Promise((resolve) => server.close(resolve))); - this.servers.clear(); - const dropped = this.dropAll(); - await Promise.all(closing); - return dropped; - } - - async start() { - for (const route of this.options.routes) await this.listen(route); - this.log('listening', { listen_ports: this.options.routes.map((r) => r.listen) }); - this.poller = setInterval(() => { this.poll().catch((error) => this.log('error', { reason: String(error.message).slice(0, 120) })); }, this.options.poll_ms); - } - - async poll() { - // setInterval does not wait for an async fault; never run two polls at once. - if (this.polling) return; - this.polling = true; - try { await this.drain(); } finally { this.polling = false; } - } - - async drain() { - for (const line of this.read()) { - let event; - try { event = JSON.parse(line); } catch { continue; } - if (event?.kind !== 'fault_request') continue; - if (this.options.run_id && event.run_id !== this.options.run_id) { this.log('ignored_foreign_run', {}); continue; } - const payload = event.payload ?? {}; - const key = `${event.monotonic_us}:${payload.type}:${payload.attempt}`; - if (this.seen.has(key)) continue; - this.seen.add(key); - await this.fault(payload); - } - } - - async fault(payload) { - const detail = { type: payload.type, attempt: payload.attempt, round: payload.round }; - if (payload.type === 'close') { - this.log('close', { ...detail, closed: this.dropAll() }); - } else if (payload.type === 'unreachable') { - if (this.unreachableTimer) { this.log('rejected', { ...detail, reason: 'unreachable window already active' }); return; } - const seconds = Math.min(MAX_UNREACHABLE_S, Math.max(1, Number.parseInt(payload.unreachable_seconds, 10) || 1)); - const closed = await this.closeListeners(); - this.log('unreachable_start', { ...detail, closed, seconds }); - this.unreachableTimer = setTimeout(async () => { - this.unreachableTimer = null; - if (this.stopped) return; - try { - for (const route of this.options.routes) await this.listen(route); - this.log('unreachable_end', detail); - } catch (error) { - this.log('error', { ...detail, reason: `relisten failed: ${error.code || error.message}` }); - } - }, seconds * 1000); - } else { - this.log('rejected', { ...detail, reason: 'unknown fault type' }); - } - } - - async stop() { - if (this.stopped) return; - this.stopped = true; - clearInterval(this.poller); - if (this.unreachableTimer) clearTimeout(this.unreachableTimer); - this.dropAll(); - await this.closeListeners(); - this.log('stopped', {}); - } -} - -async function main() { - let options; - try { options = parseArgs(process.argv.slice(2)); } catch (error) { usage(); console.error(error.message); return 2; } - if (options.help) { usage(process.stdout); return 0; } - const proxy = new FaultProxy(options); - try { await proxy.start(); } catch (error) { - console.error(`FAULT PROXY: cannot listen (${error.code || error.message})`); - await proxy.stop(); - return 2; - } - await new Promise((resolve) => { - process.once('SIGTERM', resolve); - process.once('SIGINT', resolve); - }); - await proxy.stop(); - return 0; -} - -if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) process.exitCode = await main(); diff --git a/script/playable_fault_proxy_test.mjs b/script/playable_fault_proxy_test.mjs deleted file mode 100644 index a94f96fa..00000000 --- a/script/playable_fault_proxy_test.mjs +++ /dev/null @@ -1,145 +0,0 @@ -#!/usr/bin/env node -/* STB-DISCONNECT-01 fault proxy regression. Uses only 127.0.0.1 ephemeral ports and a - * local echo server as the "upstream"; never contacts the test server. Proves that the - * proxy forwards bytes, closes the connection on `close`, refuses connections for the - * `unreachable` window and listens again, acts on each request once, ignores foreign - * run_ids, and stops cleanly on SIGTERM. */ -import fs from 'node:fs'; -import net from 'node:net'; -import os from 'node:os'; -import path from 'node:path'; -import { spawn, spawnSync } from 'node:child_process'; -import { parseArgs } from './playable_fault_proxy.mjs'; - -const script = path.join(path.dirname(new URL(import.meta.url).pathname), 'playable_fault_proxy.mjs'); -const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mt-fault-proxy.')); -let failures = 0; -const check = (ok, label) => { if (ok) console.log(`ok - ${label}`); else { failures += 1; console.error(`not ok - ${label}`); } }; -const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); - -async function until(predicate, timeoutMs, stepMs = 50) { - const end = Date.now() + timeoutMs; - while (Date.now() < end) { - if (await predicate()) return true; - await delay(stepMs); - } - return false; -} - -function freePort() { - return new Promise((resolve) => { - const server = net.createServer(); - server.listen(0, '127.0.0.1', () => { const { port } = server.address(); server.close(() => resolve(port)); }); - }); -} - -function connect(port) { - return new Promise((resolve) => { - const socket = net.connect(port, '127.0.0.1'); - const state = { socket, closed: false, data: '', error: null }; - socket.on('data', (chunk) => { state.data += chunk.toString(); }); - socket.on('close', () => { state.closed = true; }); - socket.once('connect', () => resolve(state)); - socket.once('error', (error) => { state.error = error.code; resolve(state); }); - }); -} - -const logRows = (file) => (fs.existsSync(file) ? fs.readFileSync(file, 'utf8').trim().split('\n').filter(Boolean).map((l) => JSON.parse(l)) : []); -const actions = (file, action) => logRows(file).filter((row) => row.action === action); -let usClock = 1000; -const request = (file, type, attempt, extra = {}) => fs.appendFileSync(file, `${JSON.stringify({ monotonic_us: (usClock += 1000), run_id: 'run-a', - case_id: 'STB-DISCONNECT-01', connection_epoch: 2, stage: 'FAULT', kind: 'fault_request', actor_vid: 0, target_vid: 0, - payload: { type, attempt, round: 0, timeout_ms: 30000, unreachable_seconds: type === 'unreachable' ? 2 : 0, ...extra } })}\n`); - -// Argument contract. -{ - const help = spawnSync(process.execPath, [script, '--help'], { encoding: 'utf8' }); - check(help.status === 0 && /127\.0\.0\.1/.test(help.stdout), '--help documents loopback-only listening'); - for (const [label, args] of [ - ['no routes', ['--events', 'e', '--log', 'l']], - ['malformed route', ['--route', 'abc', '--events', 'e', '--log', 'l']], - ['route to itself', ['--route', '4000=127.0.0.1:4000', '--events', 'e', '--log', 'l']], - ['port out of range', ['--route', '70000=10.0.0.1:1', '--events', 'e', '--log', 'l']], - ['unknown option', ['--route', '4000=10.0.0.1:1', '--events', 'e', '--log', 'l', '--listen-host', '0.0.0.0']], - ]) { - const run = spawnSync(process.execPath, [script, ...args], { encoding: 'utf8' }); - check(run.status === 2, `usage error exits 2: ${label}`); - } - check(parseArgs(['--route', '4000=192.0.2.1:11000', '--events', 'e', '--log', 'l']).routes[0].host === '192.0.2.1', 'route parses upstream host'); -} - -// Live behaviour against a local echo upstream. -const upstream = net.createServer((socket) => socket.pipe(socket)); -await new Promise((resolve) => upstream.listen(0, '127.0.0.1', resolve)); -const upstreamPort = upstream.address().port; -const listenPort = await freePort(); -const events = path.join(root, 'events.jsonl'); -const log = path.join(root, 'fault-proxy.jsonl'); -const proxy = spawn(process.execPath, [script, '--route', `${listenPort}=127.0.0.1:${upstreamPort}`, '--events', events, - '--log', log, '--run-id', 'run-a', '--poll-ms', '50'], { stdio: ['ignore', 'ignore', 'pipe'] }); -let exited = null; -proxy.on('exit', (code) => { exited = code; }); - -check(await until(() => actions(log, 'listening').length === 1, 5000), 'proxy logs listening before any fault'); -let a = await connect(listenPort); -a.socket.write('ping'); -check(await until(() => a.data === 'ping', 2000), 'bytes are forwarded to the upstream and back'); - -request(events, 'close', 1); -check(await until(() => a.closed, 2000), 'close request drops the proxied connection'); -check(actions(log, 'close')[0]?.closed === 1, 'close action records the dropped connection count'); -let b = await connect(listenPort); -b.socket.write('again'); -check(!b.error && await until(() => b.data === 'again', 2000), 'the listener stays open after close'); - -// The same line again (same monotonic_us/type/attempt) is not a second fault. -const lines = fs.readFileSync(events, 'utf8').trim().split('\n'); -fs.appendFileSync(events, `${lines.at(-1)}\n`); -await delay(300); -check(!b.closed && actions(log, 'close').length === 1, 'a repeated request line is acted on once'); - -fs.appendFileSync(events, `${JSON.stringify({ monotonic_us: (usClock += 1000), run_id: 'other-run', kind: 'fault_request', payload: { type: 'close', attempt: 9 } })}\n`); -await delay(300); -check(!b.closed && actions(log, 'ignored_foreign_run').length === 1, 'fault requests from another run are ignored'); - -// Partial line: only acted on once the newline arrives. -const partial = JSON.stringify({ monotonic_us: (usClock += 1000), run_id: 'run-a', kind: 'fault_request', - payload: { type: 'unreachable', attempt: 1, unreachable_seconds: 2 } }); -fs.appendFileSync(events, partial.slice(0, 20)); -await delay(300); -check(!b.closed, 'a half-written event line is not parsed early'); -const started = Date.now(); -fs.appendFileSync(events, `${partial.slice(20)}\n`); -check(await until(() => b.closed, 2000), 'unreachable request drops the connection'); -await until(() => actions(log, 'unreachable_start').length === 1, 1000); -const refused = await connect(listenPort); -check(refused.error === 'ECONNREFUSED', `connections are refused during the unreachable window (${refused.error})`); -check(await until(() => actions(log, 'unreachable_end').length === 1, 5000), 'listener comes back after unreachable_seconds'); -const elapsed = Date.now() - started; -check(elapsed >= 1900 && elapsed < 4500, `unreachable window lasts about 2 s (${elapsed} ms)`); -const c = await connect(listenPort); -c.socket.write('back'); -check(!c.error && await until(() => c.data === 'back', 2000), 'forwarding works again after the window'); -c.socket.destroy(); - -proxy.kill('SIGTERM'); -check(await until(() => exited !== null, 5000) && exited === 0, 'SIGTERM stops the proxy with exit 0'); -check(actions(log, 'stopped').length === 1, 'stop is logged'); -const after = await connect(listenPort); -check(after.error === 'ECONNREFUSED', 'no listener remains after stop'); -check(!fs.readFileSync(log, 'utf8').includes('ping'), 'the action log never contains payload bytes'); - -// A port already in use is a startup error, not a silent run. -{ - const busy = net.createServer(); - await new Promise((resolve) => busy.listen(0, '127.0.0.1', resolve)); - const run = spawnSync(process.execPath, [script, '--route', `${busy.address().port}=127.0.0.1:${upstreamPort}`, '--events', events, - '--log', path.join(root, 'busy.jsonl')], { encoding: 'utf8', timeout: 10000 }); - check(run.status === 2 && /cannot listen/.test(run.stderr), 'busy listen port exits 2'); - busy.close(); -} - -upstream.close(); -fs.rmSync(root, { recursive: true, force: true }); -console.log(failures === 0 ? 'PASS: playable_fault_proxy_test' : `FAIL: playable_fault_proxy_test (${failures})`); -process.exitCode = failures === 0 ? 0 : 1; diff --git a/script/playable_gate_test.sh b/script/playable_gate_test.sh deleted file mode 100755 index 076f2f3b..00000000 --- a/script/playable_gate_test.sh +++ /dev/null @@ -1,453 +0,0 @@ -#!/usr/bin/env bash -# INF-02 runner/exit-gate regression. Builds a throw-away ad-hoc signed arm64 -# app whose executable execs test/playable/fake_client.sh, listens on a local -# 127.0.0.1 port, and drives script/run_client_gate.sh through PASS and every -# failure path. Never starts the real client and never contacts the test server. -set -euo pipefail -set +x -cd "$(dirname "$0")/.." -repo="$PWD" -tmp_dir="$(mktemp -d "${TMPDIR:-/tmp}/mt-playable-gate.XXXXXX")" -listener_pid="" -cleanup() { - if [ -n "$listener_pid" ]; then kill "$listener_pid" 2>/dev/null || true; fi - if [ -z "${MT_GATE_TEST_KEEP:-}" ]; then rm -R "$tmp_dir"; else echo "kept $tmp_dir"; fi -} -trap cleanup EXIT - -failures=0 -pass() { echo "ok - $*"; } -fail() { echo "not ok - $*" >&2; failures=$((failures + 1)); } -expect_code() { # expected actual label - if [ "$1" -eq "$2" ]; then pass "$3 (exit $2)"; else fail "$3: expected exit $1, got $2"; fi -} - -# ---------- validator unit checks on static fixtures ---------- -fixture="$repo/test/playable" -validate_fixture() { # report log process_code timed_out [extra...] - local report="$1" log="$2" code="$3" timed="$4" - shift 4 - rm -f "$tmp_dir/sealed.json" - node script/validate_playable_report.mjs --report "$report" --output "$tmp_dir/sealed.json" --log "$log" \ - --process-code "$code" --timed-out "$timed" --run-id fixture-run --suite playable \ - --required-cases "$tmp_dir/v/required-cases.json" --build "$tmp_dir/v/build.json" \ - --events "$tmp_dir/v/events.jsonl" --started-ms "$(($(date +%s) * 1000))" --require-pass "$@" >/dev/null 2>&1 -} -reset_fixture() { - rm -rf "$tmp_dir/v" - mkdir -p "$tmp_dir/v" - cp "$fixture/report.valid.json" "$tmp_dir/v/client-report.json" - cp "$fixture/required-cases.fixture.json" "$tmp_dir/v/required-cases.json" - cp "$fixture/build.fixture.json" "$tmp_dir/v/build.json" - cp "$fixture/events.valid.jsonl" "$tmp_dir/v/events.jsonl" -} -mutate() { # js expression body operating on `r` - node -e 'const fs=require("fs");const f=process.argv[1];const r=JSON.parse(fs.readFileSync(f,"utf8"));'"$1"';fs.writeFileSync(f,JSON.stringify(r));' "$tmp_dir/v/client-report.json" -} -code_of() { set +e; "$@"; local c=$?; set -e; echo "$c"; } -quiet() { "$@" >/dev/null 2>&1; } - -reset_fixture -expect_code 0 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0)" "validator: valid fixture" -sealed_checked="$(node -e 'const r=require(process.argv[1]);process.stdout.write(String(r.exit_gate.checked===true&&r.exit_gate.process_code===0))' "$tmp_dir/sealed.json")" -[ "$sealed_checked" = "true" ] && pass "validator: exit_gate sealed" || fail "validator: exit_gate not sealed" -cp "$fixture/report.blocked.json" "$tmp_dir/v/client-report.json" -expect_code 2 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0)" "validator: BLOCKED case is not releasable" -reset_fixture -expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 1 0)" "validator: nonzero child exit" -for root_value in null '[]' '"invalid"' 42; do - reset_fixture - printf '%s\n' "$root_value" >"$tmp_dir/v/client-report.json" - expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0)" "validator: invalid root $root_value" - if [ -f "$tmp_dir/sealed.json" ]; then pass "validator: invalid root sealed as FAIL"; else fail "validator: invalid root crashed before sealing"; fi -done -reset_fixture -printf 'null\n' >"$tmp_dir/v/events.jsonl" -expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0)" "validator: null event" -[ -f "$tmp_dir/sealed.json" ] && pass "validator: null event sealed as FAIL" || fail "validator: null event crashed before sealing" -reset_fixture -expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 1)" "validator: timed out" -expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/rid-warning.log" 0 0)" "validator: RID warning in log" -expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$tmp_dir/missing.log" 0 0)" "validator: missing log" -expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/missing.json" "$fixture/clean.log" 0 0)" "validator: missing client report" -expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0 --redactor-code 143)" "validator: redactor killed" -mutate 'r.schema_version=2' -expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0)" "validator: unknown schema" -reset_fixture; mutate 'r.suite="full"' -expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0)" "validator: suite mismatch" -reset_fixture; mutate 'r.cases=[];r.required_cases=[];r.coverage={required:0,passed:0}' -expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0)" "validator: empty cases" -reset_fixture; mutate 'r.cases.push(r.cases[0]);r.coverage.required=2;r.coverage.passed=2' -expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0)" "validator: duplicate case" -reset_fixture; mutate 'r.build.pck_sha256=""' -expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0)" "validator: missing hash" -reset_fixture; mutate 'r.build.engine_sha256="d".repeat(64)' -expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0)" "validator: build differs from runner build.json" -reset_fixture; mutate 'r.cases[0].evidence=["shot.png"]' -expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0)" "validator: missing evidence file" -reset_fixture; mutate 'r.cases[0].evidence=["../../etc/hosts"]' -expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0)" "validator: evidence path escape" -reset_fixture; mutate 'r.event_count=5' -expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0)" "validator: event_count mismatch" -reset_fixture; sed 's/fixture-run/other-run/' "$fixture/events.valid.jsonl" >"$tmp_dir/v/events.jsonl" -expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0)" "validator: foreign run_id in events" -reset_fixture; node -e 'require("fs").writeFileSync(process.argv[1], JSON.stringify({schema_version:1,suite:"playable",cases:["FIXTURE-01","FIXTURE-02"]}))' "$tmp_dir/v/required-cases.json" -expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0)" "validator: required count differs from case list" -reset_fixture; touch -t 200001010000 "$tmp_dir/v/client-report.json" -expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0)" "validator: stale report mtime" -reset_fixture; mutate 'r.cases[0].reason="fixture-secret-value"' -code="$(MT_PASSWORD=fixture-secret-value code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0)" -expect_code 1 "$code" "validator: credential literal in client report" -if grep -q "fixture-secret-value" "$tmp_dir/sealed.json"; then fail "validator: secret copied into sealed report"; else pass "validator: secret not persisted"; fi - -# ---------- log redactor ---------- -redacted="$(printf 'user fixture-acct pass fixture-acct-pw\n' | MT_ACCOUNT=fixture-acct MT_PASSWORD=fixture-acct-pw node script/redact_stream.mjs)" -[ "$redacted" = "user [redacted] pass [redacted]" ] && pass "redactor: longest literal first" || fail "redactor output: $redacted" - -# ---------- scripts never trace ---------- -if grep -nE '^[[:space:]]*set[[:space:]]+-[a-zA-Z]*x' script/run_client_gate.sh script/playable_test.sh script/playable_soak.sh script/live_smoke_test.sh script/forest_map_render_test.sh 2>/dev/null; then - fail "a launch script enables set -x" -else - pass "launch scripts never enable set -x" -fi - -# ---------- example config is rejected ---------- -if MT_PLAYABLE_VALIDATE_CONFIG="$repo/test/playable/scenario.example.json" MT_PLAYABLE_ASSETS="$tmp_dir" \ - "${MT_GODOT:-godot}" --headless --path project --script playable_config_gate.gd >/dev/null 2>&1; then - fail "empty example scenario accepted" -else - pass "empty example scenario rejected" -fi - -# ---------- fake signed arm64 app ---------- -app="$tmp_dir/Fake.app" -mkdir -p "$app/Contents/MacOS" "$app/Contents/Frameworks" "$app/Contents/Resources" -cat >"$tmp_dir/fake.c" <<'EOF' -#include -#include -int main(void) { - const char *script = getenv("MT_FAKE_CHILD_SCRIPT"); - if (script == NULL) return 64; - execl("/bin/sh", "sh", script, (char *)0); - return 127; -} -EOF -printf 'int fake_extension(void) { return 1; }\n' >"$tmp_dir/ext.c" -cc -arch arm64 -o "$app/Contents/MacOS/mtgodot-poc" "$tmp_dir/fake.c" -cc -arch arm64 -dynamiclib -o "$app/Contents/Frameworks/libmtgodot.macos.template_release.dylib" "$tmp_dir/ext.c" -printf 'fake pck\n' >"$app/Contents/Resources/mtgodot-poc.pck" -cat >"$app/Contents/Info.plist" <<'EOF' - - - -CFBundleExecutablemtgodot-poc -CFBundleIdentifiertest.mt.playable-gate -CFBundlePackageTypeAPPL - -EOF -codesign --force -s - "$app/Contents/Frameworks/libmtgodot.macos.template_release.dylib" >/dev/null 2>&1 -codesign --force -s - "$app" >/dev/null 2>&1 - -# Local listener so the TCP precheck has a real endpoint; nothing reads from it. -node -e ' -const server = require("node:net").createServer((socket) => socket.destroy()); -server.listen(0, "127.0.0.1", () => console.log(server.address().port)); -' >"$tmp_dir/port" & -listener_pid=$! -for _ in 1 2 3 4 5 6 7 8 9 10; do [ -s "$tmp_dir/port" ] && break; sleep 0.2; done -port="$(tr -d '\n' <"$tmp_dir/port")" -printf 'fixture\t127.0.0.1\t%s\t127.0.0.1\t%s\t1\t10\t0\n' "$port" "$port" >"$tmp_dir/serverlist.txt" -map_key="outdoortrent/metin2_map_trent" -mkdir -p "$tmp_dir/assets/$map_key" -printf 'ScriptType\tMapSetting\nBasePosition\t0\t0\nMapSize\t2\t2\n' >"$tmp_dir/assets/$map_key/Setting.txt" -node -e ' -const [file, port, key] = process.argv.slice(1); -require("node:fs").writeFileSync(file, JSON.stringify({ - schema_version: 1, scenario_id: "gate-fixture", protocol: "classic", - server: { server_index: 0, channel: 1, auth_host: "127.0.0.1", auth_port: Number(port), game_host: "127.0.0.1", game_port: Number(port) }, - character_slot: 0, map_key: key, waypoints_cm: [[1000, 2000], [1600, 2000]], - allowed_mob_vnums: [101], allowed_drop_vnums: [19], - skill_cases: [{ case_id: "strike", skill_id: 5, target: "enemy", repeats: 2, required_evidence: ["cast_started", "damage"] }], - resolution: [1280, 720], loops: 1, timeout_seconds: 900, -}, null, 2)); -' "$tmp_dir/scenario.local.json" "$port" "$map_key" - -export MT_ACCOUNT="gate-fixture-account" -export MT_PASSWORD="gate-fixture-password" -export MT_FAKE_CHILD_SCRIPT="$repo/test/playable/fake_client.sh" -run_gate() { # scenario output [extra args...] - local scenario="$1" out="$2" - shift 2 - MT_FAKE_SCENARIO="$scenario" bash script/run_client_gate.sh --app "$app" --config "$tmp_dir/scenario.local.json" \ - --output "$out" --assets "$tmp_dir/assets" --serverlist "$tmp_dir/serverlist.txt" "$@" >"$out.stdout" 2>&1 -} -report_status() { node -e 'try{process.stdout.write(require(process.argv[1]).status)}catch{process.stdout.write("MISSING")}' "$1/report.json"; } - -cancel_gate() { # script output [runner args...] - local runner="$1" out="$2" owner child="" code pid_file - shift 2 - MT_FAKE_SCENARIO=cancel bash "$runner" --app "$app" --output "$out" "$@" >"$out.stdout" 2>&1 & - owner=$! - for ((attempt=0; attempt<100; attempt++)); do - pid_file="$(find "$out" -name fake-child.pid -print -quit 2>/dev/null || true)" - if [ -n "$pid_file" ] && [ -s "$pid_file" ]; then child="$(<"$pid_file")"; break; fi - kill -0 "$owner" 2>/dev/null || break - sleep 0.1 - done - kill -TERM "$owner" 2>/dev/null || true - set +e - wait "$owner" - code=$? - set -e - expect_code 143 "$code" "cancel: $runner preserves signal exit" - if [ -n "$child" ] && ! kill -0 "$child" 2>/dev/null; then - pass "cancel: $runner reaps its client" - else - fail "cancel: $runner left a client alive or never started one" - if [ -n "$child" ]; then kill -KILL "$child" 2>/dev/null || true; fi - fi -} -cancel_gate script/run_client_gate.sh "$tmp_dir/run-cancel" --config "$tmp_dir/scenario.local.json" \ - --assets "$tmp_dir/assets" --serverlist "$tmp_dir/serverlist.txt" -cancel_gate script/forest_map_render_test.sh "$tmp_dir/forest-cancel" --maps "$map_key" -cancel_gate script/playable_test.sh "$tmp_dir/batch-cancel" --config "$tmp_dir/scenario.local.json" \ - --assets "$tmp_dir/assets" --serverlist "$tmp_dir/serverlist.txt" - -expect_code 0 "$(code_of run_gate pass "$tmp_dir/run-pass")" "runner: PASS" -[ "$(report_status "$tmp_dir/run-pass")" = "PASS" ] && pass "runner: sealed report PASS" || fail "runner: sealed report not PASS" -expect_code 0 "$(code_of run_gate pass "$tmp_dir/run-pass-2")" "runner: second independent PASS" -expect_code 0 "$(code_of run_gate pass "$tmp_dir/run-full" --suite full)" "runner: full suite PASS" -expect_code 1 "$(code_of run_gate exit3 "$tmp_dir/run-exit3")" "runner: exit 3 after client PASS" -[ "$(report_status "$tmp_dir/run-exit3")" = "FAIL" ] && pass "runner: exit 3 sealed FAIL" || fail "runner: exit 3 not sealed FAIL" -expect_code 1 "$(code_of run_gate signal "$tmp_dir/run-signal")" "runner: child killed by signal" -expect_code 1 "$(code_of run_gate stale "$tmp_dir/run-stale")" "runner: stale run_id" -expect_code 1 "$(code_of run_gate no-report "$tmp_dir/run-noreport")" "runner: missing client report" -expect_code 1 "$(code_of run_gate rid "$tmp_dir/run-rid")" "runner: RID warning after PASS" -expect_code 1 "$(code_of run_gate leak "$tmp_dir/run-leak")" "runner: leak warning after PASS" -expect_code 1 "$(code_of run_gate missing-case "$tmp_dir/run-missing-case")" "runner: missing required case" -expect_code 1 "$(code_of run_gate missing-evidence "$tmp_dir/run-missing-evidence")" "runner: missing evidence file" -expect_code 2 "$(code_of run_gate blocked "$tmp_dir/run-blocked")" "runner: BLOCKED cases" -expect_code 0 "$(code_of run_gate print-secret "$tmp_dir/run-secret")" "runner: printed credentials are redacted before disk" -if grep -rqF -e "$MT_ACCOUNT" -e "$MT_PASSWORD" "$tmp_dir/run-secret" "$tmp_dir/run-secret.stdout"; then - fail "runner: credential literal reached the run directory" -else - pass "runner: no credential literal in run directory" -fi - -expect_code 124 "$(code_of run_gate hang "$tmp_dir/run-hang" --timeout-seconds 2)" "runner: TERM-ignoring child is killed" -if grep -qE '^TIMEOUT term_grace_s=1[0-9] killed=1$' "$tmp_dir/run-hang/gate.log"; then - pass "runner: TERM grace honoured before KILL" -else - fail "runner: timeout sequence: $(tr '\n' ' ' <"$tmp_dir/run-hang/gate.log" 2>/dev/null)" -fi -[ "$(report_status "$tmp_dir/run-hang")" = "FAIL" ] && pass "runner: timeout sealed FAIL" || fail "runner: timeout not sealed FAIL" - -expect_code 1 "$(code_of run_gate fifo-holder "$tmp_dir/run-fifo")" "runner: grandchild holding the log pipe" - -set +e -MT_FAKE_SCENARIO=exit3 bash script/run_client_gate.sh --app "$app" --config "$tmp_dir/scenario.local.json" \ - --output "$tmp_dir/run-pipe" --assets "$tmp_dir/assets" --serverlist "$tmp_dir/serverlist.txt" 2>&1 | cat >/dev/null -piped=$? -set -e -expect_code 1 "$piped" "runner: exit code survives | cat under pipefail" - -mkdir -p "$tmp_dir/run-nocreds" -code="$(code_of quiet env -u MT_ACCOUNT -u MT_PASSWORD MT_FAKE_SCENARIO=pass bash script/run_client_gate.sh --app "$app" \ - --config "$tmp_dir/scenario.local.json" --output "$tmp_dir/run-nocreds" --assets "$tmp_dir/assets" \ - --serverlist "$tmp_dir/serverlist.txt" /dev/null 2>&1' _ "$app" "$tmp_dir/scenario.local.json" "$tmp_dir/run-arch" "$tmp_dir/assets" "$tmp_dir/serverlist.txt")" "runner: missing required architecture" -printf '0\t127.0.0.1\t1\t127.0.0.1\t1\t1\t10\t0\n' >"$tmp_dir/serverlist-drift.txt" -expect_code 2 "$(code_of env bash -c 'MT_FAKE_SCENARIO=pass bash script/run_client_gate.sh --app "$1" --config "$2" --output "$3" --assets "$4" --serverlist "$5" >/dev/null 2>&1' _ "$app" "$tmp_dir/scenario.local.json" "$tmp_dir/run-drift" "$tmp_dir/assets" "$tmp_dir/serverlist-drift.txt")" "runner: config address differs from serverlist" -[ ! -e "$tmp_dir/run-drift/client.log" ] && pass "runner: client not started on address drift" || fail "runner: client started on address drift" -cp -R "$app" "$tmp_dir/Tampered.app" -printf 'tampered\n' >>"$tmp_dir/Tampered.app/Contents/Resources/mtgodot-poc.pck" -expect_code 2 "$(code_of env bash -c 'MT_FAKE_SCENARIO=pass bash script/run_client_gate.sh --app "$1" --config "$2" --output "$3" --assets "$4" --serverlist "$5" >/dev/null 2>&1' _ "$tmp_dir/Tampered.app" "$tmp_dir/scenario.local.json" "$tmp_dir/run-tampered" "$tmp_dir/assets" "$tmp_dir/serverlist.txt")" "runner: broken signature" - -# ---------- release aggregation: explicit run list only ---------- -release="$tmp_dir/release" -mkdir -p "$release" -cp -R "$tmp_dir/run-pass" "$release/run-1" -cp -R "$tmp_dir/run-pass-2" "$release/run-2" -cp -R "$tmp_dir/run-exit3" "$release/run-bad" -bash script/playable_build_info.sh "$app" >"$tmp_dir/candidate.json" -write_manifest() { # required runs-json [candidate-override-js] - node -e ' -const fs = require("node:fs"); -const [dir, required, runs, candidateFile, tweak] = process.argv.slice(1); -const candidate = JSON.parse(fs.readFileSync(candidateFile, "utf8")); -if (tweak) eval(tweak); -const list = JSON.parse(runs).map((name) => ({ run_id: JSON.parse(fs.readFileSync(`${dir}/${name}/report.json`, "utf8")).run_id, suite: "playable", path: name })); -fs.writeFileSync(`${dir}/release-manifest.json`, JSON.stringify({ schema_version: 1, candidate, required_runs: { playable: Number(required) }, runs: list })); -' "$release" "$1" "$2" "$tmp_dir/candidate.json" "${3:-}" -} -release_code() { code_of quiet node script/validate_playable_report.mjs --release-dir "$release"; } -write_manifest 2 '["run-1","run-2"]' -expect_code 0 "$(release_code)" "release: two listed PASS runs" -write_manifest 2 '["run-1"]' -expect_code 1 "$(release_code)" "release: unlisted PASS runs are never counted" -write_manifest 2 '["run-1","run-2","run-bad"]' -expect_code 1 "$(release_code)" "release: a listed FAIL run fails the release" -write_manifest 2 '["run-1","run-2"]' 'candidate.pck_sha256 = "e".repeat(64)' -expect_code 1 "$(release_code)" "release: runs from another candidate" -write_manifest 2 '["run-1","run-1"]' -expect_code 1 "$(release_code)" "release: the same run listed twice" -node -e 'const fs=require("fs");const f=process.argv[1];const m=JSON.parse(fs.readFileSync(f));m.runs[0].path="../run-pass";fs.writeFileSync(f,JSON.stringify(m));' "$release/release-manifest.json" -expect_code 1 "$(release_code)" "release: run path escapes the release directory" - -mkdir -p "$release/run-blocked" -printf 'BLOCKED 缺少 MT_ACCOUNT/MT_PASSWORD,且不是交互终端\n' >"$release/run-blocked/gate.log" -node -e 'const fs=require("fs");const f=process.argv[1];const m=JSON.parse(fs.readFileSync(f));m.runs=[m.runs[1],{run_id:"run-blocked-unsealed",suite:"playable",path:"run-blocked"}];m.required_runs={playable:2};fs.writeFileSync(f,JSON.stringify(m));' "$release/release-manifest.json" -expect_code 2 "$(release_code)" "release: precondition-blocked run is BLOCKED, not PASS" - -# ---------- batch entry: manifest written from the runs it just made ---------- -expect_code 0 "$(code_of quiet env MT_FAKE_SCENARIO=pass bash script/playable_test.sh --app "$app" --config "$tmp_dir/scenario.local.json" \ - --output "$tmp_dir/batch" --suite playable --repeat 2 --assets "$tmp_dir/assets" --serverlist "$tmp_dir/serverlist.txt")" "batch: two runs aggregate to PASS" -batch_runs="$(node -e 'const m=require(process.argv[1]);process.stdout.write(m.runs.map((r)=>r.path).join(","))' "$tmp_dir/batch/release-manifest.json")" -[ "$batch_runs" = "run-1,run-2" ] && pass "batch: manifest lists exactly its own runs" || fail "batch: manifest runs $batch_runs" -expect_code 1 "$(code_of quiet env MT_FAKE_SCENARIO=rid bash script/playable_test.sh --app "$app" --config "$tmp_dir/scenario.local.json" \ - --output "$tmp_dir/batch-rid" --suite playable --assets "$tmp_dir/assets" --serverlist "$tmp_dir/serverlist.txt")" "batch: a failing run fails the batch" -expect_code 2 "$(code_of quiet env -u MT_ACCOUNT -u MT_PASSWORD bash script/playable_test.sh --app "$app" --config "$tmp_dir/scenario.local.json" \ - --output "$tmp_dir/batch-nocreds" "$out.stdout" 2>&1 -} -expect_code 0 "$(code_of run_forest no-creds "$tmp_dir/forest-pass")" "forest: PASS without credentials in the child" -forest_cases="$(node -e 'process.stdout.write(require(process.argv[1]).cases.join(","))' "$tmp_dir/forest-pass/required-cases.json")" -[ "$forest_cases" = "MAP-FOREST-CONFIG,MAP-FOREST-LOAD-metin2_map_trent,MAP-FOREST-VP-metin2_map_trent-flat,MAP-FOREST-VP-metin2_map_trent-slope,MAP-FOREST-VP-metin2_map_trent-dense,MAP-FOREST-VP-metin2_map_trent-warp,MAP-FOREST-MOTION-metin2_map_trent" ] \ - && pass "forest: required cases follow required_case_ids()" || fail "forest: required cases $forest_cases" -grep -q "人工签核" "$tmp_dir/forest-pass.stdout" && pass "forest: manual screenshot sign-off stays pending" || fail "forest: sign-off reminder missing" -expect_code 2 "$(code_of run_forest blocked "$tmp_dir/forest-blocked")" "forest: BLOCKED viewpoints are not releasable" -expect_code 1 "$(code_of run_forest missing-case "$tmp_dir/forest-missing-case")" "forest: missing required case" -expect_code 1 "$(code_of run_forest leak "$tmp_dir/forest-leak")" "forest: leak warning after PASS" -expect_code 124 "$(code_of run_forest hang "$tmp_dir/forest-hang" --timeout-seconds 2)" "forest: TERM-ignoring child is killed" -mkdir -p "$tmp_dir/forest-nomap" -expect_code 2 "$(code_of quiet env MT_FAKE_SCENARIO=pass bash script/forest_map_render_test.sh --app "$app" --output "$tmp_dir/forest-nomap")" "forest: no map key" -[ ! -e "$tmp_dir/forest-nomap/client.log" ] && pass "forest: client not started without a map" || fail "forest: client started without a map" -expect_code 2 "$(code_of run_forest pass "$tmp_dir/forest-races" --races 2301,abc)" "forest: invalid race list" -expect_code 2 "$(code_of run_forest pass "$tmp_dir/forest-pass")" "forest: refuses a non-empty output directory" - -# ---------- STB-01 soak runner (offline; fake client, no real 2 h run) ---------- -soak_config() { # output-file server-port [faults-js] - node -e ' -const fs = require("node:fs"); -const [base, out, port, faults] = process.argv.slice(1); -const c = JSON.parse(fs.readFileSync(base, "utf8")); -c.timeout_seconds = 8100; -c.server.auth_port = Number(port); c.server.game_port = Number(port); -c.soak = { duration_seconds: 7200, rest_seconds: 30, warmup_rounds: 1, min_rounds: 10, reconnects: 10, exits: 10, - resolutions: { sizes: [[1280, 720], [1440, 900], [1600, 1000]], switches_per_size: 10 }, - warp: { status: "unconfirmed" }, faults: { status: "unconfirmed" } }; -if (faults) c.soak.faults = JSON.parse(faults); -fs.writeFileSync(out, JSON.stringify(c, null, 2)); -' "$tmp_dir/scenario.local.json" "$1" "$2" "${3:-}" -} -soak_config "$tmp_dir/soak.local.json" "$port" -run_soak_gate() { # scenario output config serverlist [extra args...] - local scenario="$1" out="$2" config="$3" list="$4" - shift 4 - MT_FAKE_SCENARIO="$scenario" bash script/run_client_gate.sh --app "$app" --config "$config" --output "$out" \ - --assets "$tmp_dir/assets" --serverlist "$list" --suite soak --allow-gameplay "$@" >"$out.stdout" 2>&1 -} -mkdir -p "$tmp_dir/soak-short" -expect_code 2 "$(code_of run_soak_gate soak-pass "$tmp_dir/soak-short" "$tmp_dir/soak.local.json" "$tmp_dir/serverlist.txt" --timeout-seconds 900)" "soak runner: timeout below duration+900" -[ ! -e "$tmp_dir/soak-short/client.log" ] && pass "soak runner: client not started with a short timeout" || fail "soak runner: client started with a short timeout" -mkdir -p "$tmp_dir/soak-noblock" -expect_code 2 "$(code_of run_soak_gate soak-pass "$tmp_dir/soak-noblock" "$tmp_dir/scenario.local.json" "$tmp_dir/serverlist.txt" --timeout-seconds 8100)" "soak runner: scenario without a soak block" -[ ! -e "$tmp_dir/soak-noblock/client.log" ] && pass "soak runner: client not started without a soak block" || fail "soak runner: client started without a soak block" - -expect_code 2 "$(code_of run_soak_gate soak-pass "$tmp_dir/soak-run" "$tmp_dir/soak.local.json" "$tmp_dir/serverlist.txt" --timeout-seconds 8100)" "soak runner: fake client without soak rounds is BLOCKED, not PASS" -soak_seal="$(node -e ' -const fs = require("node:fs"); -const dir = process.argv[1]; -const r = JSON.parse(fs.readFileSync(`${dir}/report.json`, "utf8")); -const memory = (r.runner_cases || []).find((c) => c.id === "STB-MEMORY-01"); -const rows = fs.readFileSync(`${dir}/rss.jsonl`, "utf8").trim().split("\n").filter(Boolean).length; -process.stdout.write([r.status, memory ? memory.status : "none", rows > 0 ? "rss" : "no-rss", r.exit_gate.checked].join(",")); -' "$tmp_dir/soak-run" 2>/dev/null || echo unreadable)" -[ "$soak_seal" = "BLOCKED,BLOCKED,rss,true" ] && pass "soak runner: RSS sampled for the child and STB-MEMORY-01 sealed" || fail "soak runner: seal $soak_seal" -[ ! -e "$tmp_dir/soak-run/fault-proxy.jsonl" ] && pass "soak runner: no fault proxy while faults are unconfirmed" || fail "soak runner: proxy started for unconfirmed faults" - -# Confirmed faults: client points at a loopback proxy port, proxy forwards to the local listener. -proxy_port="$(node -e 'const s=require("node:net").createServer();s.listen(0,"127.0.0.1",()=>{const p=s.address().port;s.close(()=>process.stdout.write(String(p)));})')" -printf 'fixture\t127.0.0.1\t%s\t127.0.0.1\t%s\t1\t10\t0\n' "$proxy_port" "$proxy_port" >"$tmp_dir/serverlist-proxy.txt" -soak_config "$tmp_dir/soak-proxy.local.json" "$proxy_port" "{\"status\":\"confirmed\",\"mode\":\"local_proxy\",\"per_type\":1,\"unreachable_seconds\":5,\"upstream\":{\"auth_host\":\"127.0.0.1\",\"auth_port\":$port,\"game_host\":\"127.0.0.1\",\"game_port\":$port}}" -expect_code 2 "$(code_of run_soak_gate soak-pass "$tmp_dir/soak-proxy" "$tmp_dir/soak-proxy.local.json" "$tmp_dir/serverlist-proxy.txt" --timeout-seconds 8100)" "soak runner: confirmed faults run through the loopback proxy" -proxy_actions="$(node -e 'const rows=require("node:fs").readFileSync(process.argv[1],"utf8").trim().split("\n").map((l)=>JSON.parse(l).action);process.stdout.write(String(rows[0]==="listening"&&rows.at(-1)==="stopped"))' "$tmp_dir/soak-proxy/fault-proxy.jsonl" 2>/dev/null || echo false)" -[ "$proxy_actions" = "true" ] && pass "soak runner: proxy listened before the client and stopped after it" || fail "soak runner: proxy log incomplete" -proxy_pid="$(sed -n 's/^FAULT PROXY pid=\([0-9]*\) .*/\1/p' "$tmp_dir/soak-proxy/gate.log")" -if [ -n "$proxy_pid" ] && ! kill -0 "$proxy_pid" 2>/dev/null; then pass "soak runner: proxy process is gone after the run"; else fail "soak runner: proxy pid [$proxy_pid] still alive or unknown"; fi -proxy_playable() { # output: an exit run (suite playable) with the same confirmed-faults config still reaches the server - MT_FAKE_SCENARIO=pass bash script/run_client_gate.sh --app "$app" --config "$tmp_dir/soak-proxy.local.json" --output "$1" \ - --assets "$tmp_dir/assets" --serverlist "$tmp_dir/serverlist-proxy.txt" --allow-gameplay >"$1.stdout" 2>&1 -} -expect_code 0 "$(code_of proxy_playable "$tmp_dir/soak-proxy-exit")" "soak runner: playable exit run behind the confirmed proxy" -grep -q '"action":"stopped"' "$tmp_dir/soak-proxy-exit/fault-proxy.jsonl" 2>/dev/null && [ ! -e "$tmp_dir/soak-proxy-exit/rss.jsonl" ] \ - && pass "soak runner: exit run forwards through the proxy without soak sampling" || fail "soak runner: exit run proxy/sampler state" -node -e 'const fs=require("fs");const c=JSON.parse(fs.readFileSync(process.argv[1]));c.timeout_seconds=900;fs.writeFileSync(process.argv[2],JSON.stringify(c));' "$tmp_dir/soak.local.json" "$tmp_dir/soak-short-timeout.local.json" -expect_code 0 "$(code_of quiet env MT_FAKE_SCENARIO=pass bash script/run_client_gate.sh --app "$app" --config "$tmp_dir/soak-short-timeout.local.json" \ - --output "$tmp_dir/soak-block-playable" --assets "$tmp_dir/assets" --serverlist "$tmp_dir/serverlist.txt")" "soak runner: playable suite ignores the soak-only timeout margin" -bad_soak="$tmp_dir/soak-bad-playable.local.json" -node -e 'const fs=require("fs");const c=JSON.parse(fs.readFileSync(process.argv[1]));c.soak.faults.upstream.auth_port=70000;fs.writeFileSync(process.argv[2],JSON.stringify(c));' "$tmp_dir/soak-proxy.local.json" "$bad_soak" -mkdir -p "$tmp_dir/soak-bad-playable" -expect_code 2 "$(code_of quiet env MT_FAKE_SCENARIO=pass bash script/run_client_gate.sh --app "$app" --config "$bad_soak" --output "$tmp_dir/soak-bad-playable" \ - --assets "$tmp_dir/assets" --serverlist "$tmp_dir/serverlist-proxy.txt")" "soak runner: an invalid soak block is rejected for every suite" -grep -q "soak.faults.upstream.auth_port" "$tmp_dir/soak-bad-playable/config.log" 2>/dev/null && [ ! -e "$tmp_dir/soak-bad-playable/client.log" ] \ - && pass "soak runner: config gate names the invalid upstream before any client" || fail "soak runner: invalid soak block not caught by the config gate" -mkdir -p "$tmp_dir/soak-proxy-down" -soak_config "$tmp_dir/soak-proxy-down.local.json" "$proxy_port" "{\"status\":\"confirmed\",\"mode\":\"local_proxy\",\"per_type\":1,\"unreachable_seconds\":5,\"upstream\":{\"auth_host\":\"127.0.0.1\",\"auth_port\":1,\"game_host\":\"127.0.0.1\",\"game_port\":1}}" -expect_code 2 "$(code_of run_soak_gate soak-pass "$tmp_dir/soak-proxy-down" "$tmp_dir/soak-proxy-down.local.json" "$tmp_dir/serverlist-proxy.txt" --timeout-seconds 8100)" "soak runner: unreachable fault-proxy upstream" -[ ! -e "$tmp_dir/soak-proxy-down/client.log" ] && pass "soak runner: client not started when the upstream is down" || fail "soak runner: client started with upstream down" - -# ---------- STB-01 public entry: script/playable_soak.sh ---------- -cancel_gate script/playable_soak.sh "$tmp_dir/soak-cancel" --config "$tmp_dir/soak.local.json" \ - --assets "$tmp_dir/assets" --serverlist "$tmp_dir/serverlist.txt" --allow-gameplay -run_soak() { # scenario output [extra args...] - local scenario="$1" out="$2" - shift 2 - MT_FAKE_SCENARIO="$scenario" bash script/playable_soak.sh --app "$app" --config "$tmp_dir/soak.local.json" --output "$out" \ - --assets "$tmp_dir/assets" --serverlist "$tmp_dir/serverlist.txt" "$@" "$out.stdout" 2>&1 -} -expect_code 0 "$(code_of quiet bash script/playable_soak.sh --help)" "soak: --help" -expect_code 2 "$(code_of quiet bash script/playable_soak.sh --app "$app" /dev/null)" ]; then fail "soak: $dir started a client"; fi -done -expect_code 2 "$(code_of quiet env -u MT_ACCOUNT -u MT_PASSWORD MT_FAKE_SCENARIO=soak-pass bash script/playable_soak.sh --app "$app" \ - --config "$tmp_dir/soak.local.json" --output "$tmp_dir/soak-nocreds" --assets "$tmp_dir/assets" --serverlist "$tmp_dir/serverlist.txt" --allow-gameplay /dev/null \ - && pass "soak: 2 h soak not started after a failed exit run" || fail "soak: soak run started after a failed exit run" - -expect_code 2 "$(code_of run_soak soak-pass "$tmp_dir/soak-batch" --allow-gameplay)" "soak: fake batch stays BLOCKED without real soak rounds" -soak_runs="$(node -e 'const m=require(process.argv[1]);process.stdout.write(m.runs.map((r)=>`${r.path}:${r.suite}`).join(",")+"|"+JSON.stringify(m.required_runs))' "$tmp_dir/soak-batch/release-manifest.json" 2>/dev/null || echo missing)" -expected_runs="$(for i in 1 2 3 4 5 6 7 8 9 10; do printf 'exit-%s:playable,' "$i"; done)soak-1:soak|{\"playable\":10,\"soak\":1}" -[ "$soak_runs" = "$expected_runs" ] && pass "soak: manifest lists 10 exit runs and one soak run" || fail "soak: manifest $soak_runs" -exit_passes="$(node -e 'const r=require(process.argv[1]);process.stdout.write(String(r.counts.playable.passed)+"/"+r.status)' "$tmp_dir/soak-batch/release-report.json" 2>/dev/null || echo missing)" -[ "$exit_passes" = "10/BLOCKED" ] && pass "soak: 10 independent exit runs PASS, release BLOCKED by memory evidence" || fail "soak: release $exit_passes" -if grep -rqF -e "$MT_ACCOUNT" -e "$MT_PASSWORD" "$tmp_dir/soak-batch" "$tmp_dir/soak-batch.stdout"; then - fail "soak: credential literal reached the batch directory" -else - pass "soak: no credential literal in the batch directory" -fi -expect_code 2 "$(code_of run_soak soak-pass "$tmp_dir/soak-batch" --allow-gameplay)" "soak: refuses a non-empty output directory" - -if [ "$failures" -ne 0 ]; then - echo "playable_gate_test: FAIL ($failures)" >&2 - exit 1 -fi -echo "playable_gate_test: PASS" diff --git a/script/playable_process_cleanup.sh b/script/playable_process_cleanup.sh deleted file mode 100644 index 0feaef27..00000000 --- a/script/playable_process_cleanup.sh +++ /dev/null @@ -1,37 +0,0 @@ -#!/usr/bin/env bash -# Sourced by runners. Only signal children whose PIDs this runner still owns. -# Clear each PID immediately after wait, so EXIT cannot signal a reused PID. -child_pid="" -redactor_pid="" -sampler_pid="" -proxy_pid="" - -cleanup_playable_processes() { - local status=$? pid round alive - trap - EXIT - trap '' HUP INT TERM - for pid in "$child_pid" "$redactor_pid" "$sampler_pid" "$proxy_pid"; do - if [ -n "$pid" ]; then kill -TERM "$pid" 2>/dev/null || true; fi - done - for ((round=0; round<${process_cleanup_grace_seconds:-10}; round++)); do - alive=0 - for pid in "$child_pid" "$redactor_pid" "$sampler_pid" "$proxy_pid"; do - if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then alive=1; fi - done - [ "$alive" -eq 1 ] || break - sleep 1 - done - for pid in "$child_pid" "$redactor_pid" "$sampler_pid" "$proxy_pid"; do - if [ -n "$pid" ]; then - kill -KILL "$pid" 2>/dev/null || true - wait "$pid" 2>/dev/null || true - fi - done - if [ -n "${fifo:-}" ] && [ -p "$fifo" ]; then rm -f "$fifo"; fi - return "$status" -} - -trap cleanup_playable_processes EXIT -trap 'exit 129' HUP -trap 'exit 130' INT -trap 'exit 143' TERM diff --git a/script/playable_release_test.mjs b/script/playable_release_test.mjs deleted file mode 100644 index 88f6cc83..00000000 --- a/script/playable_release_test.mjs +++ /dev/null @@ -1,46 +0,0 @@ -#!/usr/bin/env node -// Release aggregation must reject the intermediate report before the RSS seal. -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; -import { fileURLToPath } from 'node:url'; - -const repo = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); -const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mt-playable-release-')); -const write = (name, value) => fs.writeFileSync(path.join(root, name), JSON.stringify(value)); -const fixture = JSON.parse(fs.readFileSync(path.join(repo, 'test/playable/report.valid.json'), 'utf8')); -const manifest = { schema_version: 1, candidate: fixture.build, required_runs: { soak: 1 }, - runs: [{ run_id: fixture.run_id, suite: 'soak', path: 'run-1' }] }; -const report = { ...fixture, suite: 'soak', exit_gate: { checked: true, process_code: 0, timed_out: false, errors: [] } }; -const baseline = process.argv.includes('--baseline') - ? spawnSync('git', ['show', 'f39a55fd:script/validate_playable_report.mjs'], { cwd: repo, encoding: 'utf8' }).stdout : null; -function check(expected, label) { - const args = baseline ? ['--input-type=module', '-'] : [path.join(repo, 'script/validate_playable_report.mjs')]; - const result = spawnSync(process.execPath, [...args, '--release-dir', root], { input: baseline, encoding: 'utf8' }); - assert.equal(result.status, expected, `${label}: ${result.stdout} ${result.stderr}`); - assert.equal(JSON.parse(fs.readFileSync(path.join(root, 'release-report.json'))).status, expected === 0 ? 'PASS' : 'FAIL'); - console.log(`ok - ${label}`); -} -try { - fs.mkdirSync(path.join(root, 'run-1')); - write('release-manifest.json', manifest); - write('run-1/report.json', report); - check(1, 'soak PASS without external memory seal is rejected'); - report.runner_cases = [{ id: 'STB-MEMORY-01', status: 'PASS' }]; - write('run-1/report.json', report); - check(1, 'memory case alone cannot replace the verdict'); - report.memory = { verdict: { status: 'FAIL' } }; - write('run-1/report.json', report); - check(1, 'contradictory memory verdict is rejected'); - report.memory.verdict.status = 'PASS'; - write('run-1/report.json', report); - check(0, 'clean exit and external memory PASS can be aggregated'); - write('run-1/report.json', null); - check(1, 'null run report produces a sealed release failure'); - write('release-manifest.json', null); - check(1, 'null manifest produces a sealed release failure'); -} finally { - fs.rmSync(root, { recursive: true, force: true }); -} diff --git a/script/playable_soak.sh b/script/playable_soak.sh deleted file mode 100644 index 49436781..00000000 --- a/script/playable_soak.sh +++ /dev/null @@ -1,198 +0,0 @@ -#!/usr/bin/env bash -# STB-01 public entry: 2 h wall-clock soak plus independent normal-exit runs. -# -# Layout under --output (must be empty): -# exit-/ N independent client processes, suite playable, each through -# run_client_gate.sh (normal quit, exit 0, no leak/RID warnings) -# soak-1/ one client process, suite soak: move/combat/pickup rounds until the -# wall-clock duration, active reconnects, resolution switches, and — -# only when the scenario confirms them — portal warps and real -# transport faults through the 127.0.0.1 fault proxy; RSS is sampled -# for that child PID and sealed as STB-MEMORY-01 -# release-manifest.json listing exactly these runs; aggregated with -# validate_playable_report.mjs --release-dir (no historical reports are scanned). -# -# Exit runs go first; if one is not PASS the remaining runs (including the 2 h soak) -# are not started and are recorded as BLOCKED so the release cannot pass. -# Credentials only from MT_ACCOUNT/MT_PASSWORD or one hidden TTY prompt. -# Do not add `set -x` to this script. Never pkill; never touch routes/firewall/caches. -# -# exit: 0 PASS, 1 FAIL, 2 BLOCKED (config/precondition/unconfirmed environment), 124 timeout. -set -euo pipefail -set +x -cd "$(dirname "$0")/.." -repo="$PWD" -process_cleanup_grace_seconds=15 # Allow the child runner's 10-second cleanup to finish. -source script/playable_process_cleanup.sh -app="${MT_PLAYABLE_APP:-}" -config="" -output="" -duration="" -exits="" -timeout="" -exit_timeout="900" -allow=0 -assets="" -serverlist="${MT_PLAYABLE_SERVERLIST:-}" - -usage() { - cat <<'EOF' -用法: script/playable_soak.sh --app APP --config CONFIG --allow-gameplay [选项] - --app APP 候选导出包(所有 run 必须同一哈希) - --config CONFIG 场景 JSON(含 soak 块;不含凭据) - --output DIR 本批次根目录,必须不存在或为空(默认 build/playable/soak-<时间>-) - --duration-seconds N 墙钟时长,必须等于配置 soak.duration_seconds(默认取配置,>= 7200) - --exits N 独立正常退出运行次数(默认配置 soak.exits,>= 10) - --timeout-seconds N soak 进程墙钟超时(默认 duration+900,不得更小) - --exit-timeout-seconds N 每个退出运行的墙钟超时,默认 900 - --assets DIR 资源预检根目录(透传 run_client_gate.sh) - --serverlist FILE 测试模式 serverlist 覆盖(透传) - --allow-gameplay 允许专用测试账号发送移动/战斗/拾取请求(不给则只校验配置,返回 2,不启动客户端) -环境变量: - MT_ACCOUNT / MT_PASSWORD 测试账号(未设置且 stdin 是终端时隐藏输入一次) -说明: - 切图(soak.warp)和真实断网(soak.faults)须由环境负责人确认后在配置里标 confirmed; - 未确认时对应用例为 BLOCKED,不用主动重连或直接传送替代。 -退出码: 0 PASS / 1 FAIL / 2 BLOCKED / 124 超时 -EOF -} - -blocked() { echo "PLAYABLE SOAK: BLOCKED $*" >&2; exit 2; } -positive() { [[ "$1" =~ ^[1-9][0-9]*$ ]]; } - -while [ "$#" -gt 0 ]; do - case "$1" in - --app) app="${2:-}"; shift 2 ;; - --config) config="${2:-}"; shift 2 ;; - --output) output="${2:-}"; shift 2 ;; - --duration-seconds) duration="${2:-}"; shift 2 ;; - --exits) exits="${2:-}"; shift 2 ;; - --timeout-seconds) timeout="${2:-}"; shift 2 ;; - --exit-timeout-seconds) exit_timeout="${2:-}"; shift 2 ;; - --assets) assets="${2:-}"; shift 2 ;; - --serverlist) serverlist="${2:-}"; shift 2 ;; - --allow-gameplay) allow=1; shift ;; - --help) usage; exit 0 ;; - *) echo "未知选项: $1" >&2; usage >&2; exit 2 ;; - esac -done - -[ -n "$app" ] && [ -d "$app" ] || blocked "需要 --app 指向候选导出包" -[ -n "$config" ] && [ -f "$config" ] || blocked "需要 --config CONFIG" -config="$(cd "$(dirname "$config")" && pwd)/$(basename "$config")" -for pair in "duration-seconds:$duration" "exits:$exits" "timeout-seconds:$timeout"; do - value="${pair#*:}" - if [ -n "$value" ] && ! positive "$value"; then blocked "--${pair%%:*} 必须是正整数"; fi -done -positive "$exit_timeout" || blocked "--exit-timeout-seconds 必须是正整数" - -# Config validation before any prompt or process: the same gate the runner uses. -if [ -z "$assets" ]; then - if [ -d "$app/Contents/Resources/assets" ]; then assets="$app/Contents/Resources/assets"; else assets="$repo/assets"; fi -fi -if ! config_log="$(env MT_PLAYABLE_VALIDATE_CONFIG="$config" MT_PLAYABLE_SUITE=soak MT_PLAYABLE_ASSETS="$assets" \ - MT_PLAYABLE_SERVERLIST="$serverlist" "${MT_GODOT:-godot}" --headless --path project --script playable_config_gate.gd 2>&1)"; then - printf '%s\n' "$config_log" | grep '^CONFIG:' >&2 || true - blocked "配置/资源预检失败(suite soak)" -fi -read -r cfg_duration cfg_exits <<<"$(node -e ' -const s = JSON.parse(require("node:fs").readFileSync(process.argv[1], "utf8")).soak; -process.stdout.write(`${s.duration_seconds} ${s.exits}`); -' "$config")" -if [ -z "$duration" ]; then duration="$cfg_duration"; fi -[ "$duration" -eq "$cfg_duration" ] || blocked "--duration-seconds $duration 与配置 soak.duration_seconds $cfg_duration 不一致;请改配置而不是覆盖" -if [ -z "$exits" ]; then exits="$cfg_exits"; fi -[ "$exits" -ge 10 ] || blocked "--exits 至少 10" -minimum_timeout=$((duration + 900)) -if [ -z "$timeout" ]; then timeout="$minimum_timeout"; fi -[ "$timeout" -ge "$minimum_timeout" ] || blocked "--timeout-seconds 不得小于 duration+900(${minimum_timeout})" -# Without explicit consent only the configuration is checked; no client, no gameplay request. -[ "$allow" -eq 1 ] || blocked "配置校验通过;soak 需要 --allow-gameplay(专用测试账号上的移动/战斗/拾取),未启动客户端" - -root="$output" -if [ -z "$root" ]; then root="$repo/build/playable/soak-$(date +%Y%m%d-%H%M%S)-$$"; fi -if [ -e "$root" ] && [ -n "$(find "$root" -mindepth 1 -maxdepth 1 -print -quit)" ]; then - blocked "输出目录已有内容,拒绝混入历史运行:$root" -fi -mkdir -p "$root" -root="$(cd "$root" && pwd)" - -if [ -z "${MT_ACCOUNT:-}" ] || [ -z "${MT_PASSWORD:-}" ]; then - if [ -t 0 ]; then - if [ -z "${MT_ACCOUNT:-}" ]; then read -r -s -p "测试账号: " MT_ACCOUNT; echo >&2; fi - if [ -z "${MT_PASSWORD:-}" ]; then read -r -s -p "测试密码: " MT_PASSWORD; echo >&2; fi - fi -fi -[ -n "${MT_ACCOUNT:-}" ] && [ -n "${MT_PASSWORD:-}" ] || blocked "缺少 MT_ACCOUNT/MT_PASSWORD,且不是交互终端" -export MT_ACCOUNT MT_PASSWORD - -bash script/playable_build_info.sh "$app" >"$root/candidate.json" - -runs_json="[]" -timed_out=0 -stop_reason="" -list_run() { # name suite - runs_json="$(node -e ' -const fs = require("node:fs"); -const [runs, dir, name, suite] = process.argv.slice(1); -let runId = `${name}-unsealed`; -try { runId = JSON.parse(fs.readFileSync(`${dir}/report.json`, "utf8")).run_id || runId; } catch {} -const list = JSON.parse(runs); -list.push({ run_id: runId, suite, path: name }); -process.stdout.write(JSON.stringify(list)); -' "$runs_json" "$root/$1" "$1" "$2")" -} -run_one() { # name suite timeout - local name="$1" run_suite="$2" run_timeout="$3" code - if [ -n "$stop_reason" ]; then - mkdir -p "$root/$name" - echo "BLOCKED not started: $stop_reason" >"$root/$name/gate.log" - list_run "$name" "$run_suite" - return - fi - local args=(--app "$app" --config "$config" --output "$root/$name" --suite "$run_suite" --timeout-seconds "$run_timeout" --allow-gameplay) - if [ -n "$assets" ]; then args+=(--assets "$assets"); fi - if [ -n "$serverlist" ]; then args+=(--serverlist "$serverlist"); fi - set +e - bash script/run_client_gate.sh "${args[@]}" c.id.startsWith("STB-")), ...(r.runner_cases || [])]) console.log(` ${c.id} ${c.status} ${c.reason || ""}`); -' "$root/soak-1/report.json" || true -fi -if [ "$timed_out" -eq 1 ]; then echo "PLAYABLE SOAK: TIMEOUT root=$root" >&2; exit 124; fi -case "$release_code" in - 0) echo "PLAYABLE SOAK: PASS exits=$exits duration=${duration}s root=$root" ;; - 2) echo "PLAYABLE SOAK: BLOCKED root=$root" >&2 ;; - *) echo "PLAYABLE SOAK: FAIL root=$root" >&2; release_code=1 ;; -esac -exit "$release_code" diff --git a/script/playable_soak_metrics.mjs b/script/playable_soak_metrics.mjs deleted file mode 100644 index 1caeab13..00000000 --- a/script/playable_soak_metrics.mjs +++ /dev/null @@ -1,242 +0,0 @@ -#!/usr/bin/env node -/* STB-01 §9.1 external memory probe and leak gate. - * - * playable_soak_metrics.mjs --sample-rss --pid PID --output rss.jsonl [--interval-ms 1000] - * Appends {wall_ms, rss_kib} for exactly one PID (the runner's own child) via - * `ps -o stat=,rss= -p PID` once per interval and exits when that PID is gone or a zombie. It - * never signals the PID. A failed read is written as rss_kib null, not 0. - * - * playable_soak_metrics.mjs --seal --report report.json --rss rss.jsonl --events events.jsonl - * [--warmup-rounds 1] [--min-rounds 10] - * Reads the client's soak_rest start/end events (payload.round, payload.wall_ms), - * takes the RSS low water inside each >=30 s rest window, applies the leak rule - * and writes report.memory + runner_cases[STB-MEMORY-01] back into the sealed - * report. The verdict can only downgrade the report status. - * - * Leak rule: after warm-up, FAIL when the low water grew in >=5 consecutive rounds - * AND median(last 5) - median(first 5) > max(50 MiB, 5% of median(first 5)). - * Below the threshold is not proof that there is no long-term leak. - * - * exit: 0 PASS, 1 FAIL, 2 BLOCKED or usage error. */ -import fs from 'node:fs'; -import path from 'node:path'; -import { execFile } from 'node:child_process'; -import { fileURLToPath } from 'node:url'; - -const REST_MIN_MS = 30000; -const STREAK = 5; -const ABS_THRESHOLD_KIB = 50 * 1024; -const PCT_THRESHOLD = 0.05; -const NOTE = '阈值以下不能证明长期无泄漏;GPU 内存不可得(null/unavailable),不据此判断显存。'; -const CASE_ID = 'STB-MEMORY-01'; -const RANK = { PASS: 0, BLOCKED: 1, FAIL: 2 }; - -export function median(values) { - const sorted = [...values].sort((a, b) => a - b); - if (sorted.length === 0) return null; - const mid = Math.floor(sorted.length / 2); - return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2; -} - -export function parseRss(text) { - const samples = []; - let badLines = 0; - for (const line of String(text).split('\n')) { - if (line.trim() === '') continue; - try { - const row = JSON.parse(line); - if (!Number.isFinite(row.wall_ms) || !(row.rss_kib === null || Number.isInteger(row.rss_kib))) badLines += 1; - else samples.push({ wall_ms: row.wall_ms, rss_kib: row.rss_kib }); - } catch { badLines += 1; } - } - return { samples, bad_lines: badLines }; -} - -/** Pair soak_rest start/end events per round. Unpaired or reordered events are errors. */ -export function restWindows(eventsText) { - const open = new Map(); - const windows = []; - const errors = []; - for (const line of String(eventsText).split('\n')) { - if (line.trim() === '') continue; - let event; - try { event = JSON.parse(line); } catch { continue; } - if (event?.kind !== 'soak_rest') continue; - const { round, state, wall_ms: wallMs } = event.payload ?? {}; - if (!Number.isInteger(round) || !Number.isFinite(wallMs)) { errors.push('soak_rest event without integer round/wall_ms'); continue; } - if (state === 'start') { - if (open.has(round)) errors.push(`round ${round}: rest started twice`); - open.set(round, wallMs); - } else if (state === 'end') { - if (!open.has(round)) { errors.push(`round ${round}: rest ended without a start`); continue; } - const start = open.get(round); - open.delete(round); - if (wallMs < start) errors.push(`round ${round}: rest ends before it starts`); - else windows.push({ round, start_ms: start, end_ms: wallMs, rest_ms: wallMs - start }); - } - } - for (const round of open.keys()) errors.push(`round ${round}: rest never ended`); - windows.sort((a, b) => a.round - b.round); - return { windows, errors }; -} - -export function roundLowWaters(windows, samples) { - return windows.map((window) => { - const inside = samples.filter((s) => s.wall_ms >= window.start_ms && s.wall_ms <= window.end_ms && Number.isInteger(s.rss_kib)); - // At least one sample per two seconds of rest; a sparse window is not a measured low water. - const needed = Math.max(1, Math.floor(window.rest_ms / 2000)); - return { - round: window.round, - rest_ms: window.rest_ms, - samples: inside.length, - low_water_kib: inside.length >= needed ? Math.min(...inside.map((s) => s.rss_kib)) : null, - }; - }); -} - -export function leakVerdict(lowWaters, { warmupRounds = 1, minRounds = 10 } = {}) { - const verdict = { - status: 'BLOCKED', reason: '', unit: 'KiB', warmup_rounds: warmupRounds, rounds_total: lowWaters.length, rounds_analysed: 0, - first5_median_kib: null, last5_median_kib: null, growth_kib: null, threshold_kib: null, max_growth_streak: 0, - rule: `FAIL iff >=${STREAK} consecutive growing rounds AND last5 median - first5 median > max(50MiB, 5%)`, - warnings: [], note: NOTE, - }; - const short = lowWaters.find((row) => row.rest_ms < REST_MIN_MS); - if (short) return { ...verdict, status: 'FAIL', reason: `round ${short.round}: rest ${short.rest_ms} ms is shorter than 30 s` }; - const rows = lowWaters.slice(warmupRounds); - verdict.rounds_analysed = rows.length; - if (rows.length < minRounds) return { ...verdict, reason: `need at least ${minRounds} rounds after warm-up, got ${rows.length}` }; - const missing = rows.find((row) => row.low_water_kib === null); - if (missing) return { ...verdict, reason: `round ${missing.round}: no RSS low water (sampler gap)` }; - const values = rows.map((row) => row.low_water_kib); - let streak = 0; - for (let i = 1; i < values.length; i += 1) { - streak = values[i] > values[i - 1] ? streak + 1 : 0; - verdict.max_growth_streak = Math.max(verdict.max_growth_streak, streak); - } - const first = Math.round(median(values.slice(0, STREAK))); - const last = Math.round(median(values.slice(-STREAK))); - verdict.first5_median_kib = first; - verdict.last5_median_kib = last; - verdict.growth_kib = last - first; - verdict.threshold_kib = Math.max(ABS_THRESHOLD_KIB, Math.ceil(PCT_THRESHOLD * first)); - const overThreshold = verdict.growth_kib > verdict.threshold_kib; - const streakHit = verdict.max_growth_streak >= STREAK; - if (overThreshold && streakHit) { - verdict.status = 'FAIL'; - verdict.reason = `low water grew ${verdict.max_growth_streak} rounds in a row and +${verdict.growth_kib} KiB > ${verdict.threshold_kib} KiB; analyse before release`; - return verdict; - } - if (overThreshold) verdict.warnings.push(`median growth +${verdict.growth_kib} KiB exceeds the threshold without ${STREAK} consecutive growing rounds; needs analysis`); - verdict.status = 'PASS'; - verdict.reason = 'no leak signal at the gate threshold'; - return verdict; -} - -export function sealMemory(report, { eventsText, rssText, warmupRounds = 1, minRounds = 10 }) { - const rss = parseRss(rssText ?? ''); - const rest = restWindows(eventsText ?? ''); - const rounds = roundLowWaters(rest.windows, rss.samples); - let verdict = leakVerdict(rounds, { warmupRounds, minRounds }); - if (rest.errors.length > 0) verdict = { ...verdict, status: 'FAIL', reason: `rest events: ${rest.errors.slice(0, 5).join('; ')}` }; - else if (rest.windows.length === 0) verdict = { ...verdict, status: 'BLOCKED', reason: 'no soak_rest windows in events' }; - // The client's own MEMORY_STATIC snapshots stay separate from the external RSS. - const godot = report.memory?.godot ?? report.memory ?? null; - report.memory = { - sampler: 'external ps -o stat=,rss= on the runner-held child PID, 1 Hz', - unit: 'KiB', - sample_count: rss.samples.length, - samples_null: rss.samples.filter((s) => s.rss_kib === null).length, - bad_lines: rss.bad_lines, - rounds, - gpu_memory_kib: null, - gpu_memory_status: 'unavailable', - godot, - verdict, - }; - report.runner_cases = (Array.isArray(report.runner_cases) ? report.runner_cases : []).filter((c) => c?.id !== CASE_ID); - report.runner_cases.push({ id: CASE_ID, status: verdict.status, reason: verdict.reason }); - if (!VALID_STATUS(report.status)) report.status = 'FAIL'; - if (RANK[verdict.status] > RANK[report.status]) report.status = verdict.status; - report.exit_gate = report.exit_gate && typeof report.exit_gate === 'object' ? report.exit_gate : { checked: false, errors: [] }; - if (!Array.isArray(report.exit_gate.errors)) report.exit_gate.errors = []; - if (verdict.status !== 'PASS') report.exit_gate.errors.push(`${CASE_ID}: ${verdict.status} ${verdict.reason}`); - return { report, code: { PASS: 0, FAIL: 1, BLOCKED: 2 }[report.status] ?? 1 }; -} - -function VALID_STATUS(status) { return Object.hasOwn(RANK, status); } - -function usage(stream = process.stderr) { - stream.write('usage: playable_soak_metrics.mjs --sample-rss --pid PID --output rss.jsonl [--interval-ms 1000]\n' - + ' playable_soak_metrics.mjs --seal --report report.json --rss rss.jsonl --events events.jsonl [--warmup-rounds 1] [--min-rounds 10]\n'); -} - -function argsOf(argv) { - const out = {}; - for (let i = 0; i < argv.length; i += 1) { - const arg = argv[i]; - if (['--sample-rss', '--seal', '--help'].includes(arg)) out[arg.slice(2).replace('-', '_')] = true; - else if (arg.startsWith('--') && i + 1 < argv.length) out[arg.slice(2).replaceAll('-', '_')] = argv[++i]; - else throw new Error(`bad argument: ${arg}`); - } - return out; -} - -function readRss(pid) { - return new Promise((resolve) => { - execFile('ps', ['-o', 'stat=,rss=', '-p', String(pid)], { timeout: 5000 }, (error, stdout) => { - if (error) { resolve({ alive: false }); return; } - const [stat = '', rss = ''] = String(stdout).trim().split(/\s+/); - // An exited child the runner has not reaped yet is a zombie: it is gone, not 0 KiB. - if (stat.startsWith('Z')) { resolve({ alive: false }); return; } - const value = Number.parseInt(rss, 10); - resolve({ alive: true, rss_kib: Number.isInteger(value) && value > 0 ? value : null }); - }); - }); -} - -async function sampleRss(options) { - const pid = Number(options.pid); - const interval = Number(options.interval_ms ?? 1000); - if (!Number.isInteger(pid) || pid <= 0 || !options.output || !Number.isInteger(interval) || interval < 100) { usage(); return 2; } - let stop = false; - process.on('SIGTERM', () => { stop = true; }); - process.on('SIGINT', () => { stop = true; }); - while (!stop) { - const tick = Date.now(); - const row = await readRss(pid); - if (!row.alive) break; - fs.appendFileSync(options.output, `${JSON.stringify({ wall_ms: tick, rss_kib: row.rss_kib })}\n`); - await new Promise((resolve) => setTimeout(resolve, Math.max(0, interval - (Date.now() - tick)))); - } - return 0; -} - -function seal(options) { - for (const key of ['report', 'rss', 'events']) if (!options[key]) { usage(); return 2; } - let report; - try { report = JSON.parse(fs.readFileSync(options.report, 'utf8')); } - catch { console.error(`PLAYABLE MEMORY: FAIL report unreadable ${path.basename(options.report)}`); return 1; } - const read = (file) => (fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : ''); - const { report: sealed, code } = sealMemory(report, { - eventsText: read(options.events), rssText: read(options.rss), - warmupRounds: Number(options.warmup_rounds ?? 1), minRounds: Number(options.min_rounds ?? 10), - }); - fs.writeFileSync(options.report, `${JSON.stringify(sealed, null, 2)}\n`); - const v = sealed.memory.verdict; - const line = `PLAYABLE MEMORY: ${v.status} ${v.reason} (first5=${v.first5_median_kib} last5=${v.last5_median_kib} KiB)`; - if (v.status === 'PASS') console.log(line); else console.error(line); - return code; -} - -async function main() { - let options; - try { options = argsOf(process.argv.slice(2)); } catch (error) { usage(); console.error(error.message); return 2; } - if (options.help) { usage(process.stdout); return 0; } - if (options.sample_rss) return sampleRss(options); - if (options.seal) return seal(options); - usage(); - return 2; -} - -if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) process.exitCode = await main(); diff --git a/script/playable_soak_metrics_test.mjs b/script/playable_soak_metrics_test.mjs deleted file mode 100644 index 17b83c48..00000000 --- a/script/playable_soak_metrics_test.mjs +++ /dev/null @@ -1,150 +0,0 @@ -#!/usr/bin/env node -/* STB-01 §9.1 memory gate regression. Synthetic RSS/rest-window data proves the - * leak rule (5 consecutive growing low waters AND last-5 median above - * max(50MiB, 5%) of the first-5 median), that missing data is BLOCKED rather - * than PASS, and that the external sampler records KiB for one PID only. */ -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { spawn, spawnSync } from 'node:child_process'; -import { parseRss, restWindows, roundLowWaters, leakVerdict, median, sealMemory } from './playable_soak_metrics.mjs'; - -const script = path.join(path.dirname(new URL(import.meta.url).pathname), 'playable_soak_metrics.mjs'); -const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mt-soak-metrics.')); -let failures = 0; -const check = (ok, label) => { if (ok) console.log(`ok - ${label}`); else { failures += 1; console.error(`not ok - ${label}`); } }; - -const MIB = 1024; -const event = (round, state, wallMs, us) => JSON.stringify({ monotonic_us: us, run_id: 'r', case_id: 'STB-MEMORY-01', connection_epoch: 1, - stage: 'REST', kind: 'soak_rest', actor_vid: 0, target_vid: 0, payload: { round, state, wall_ms: wallMs } }); - -/** Build rounds of rest windows (30 s each, 1 Hz samples) whose low water follows `lows` (KiB). */ -function fixture(lows, { restMs = 30000, gapMs = 60000, dropSamplesInRound = -1 } = {}) { - const events = []; - const rss = []; - let t = 1_700_000_000_000; - lows.forEach((low, round) => { - for (let s = 1; s < gapMs / 1000; s += 1) rss.push({ wall_ms: t + s * 1000, rss_kib: low + 80 * MIB }); - t += gapMs; - events.push(event(round, 'start', t, round * 10 + 1)); - for (let s = 0; s <= restMs / 1000; s += 1) { - if (round !== dropSamplesInRound) rss.push({ wall_ms: t + s * 1000, rss_kib: low + (s === 20 ? 0 : 3 * MIB) }); - } - t += restMs; - events.push(event(round, 'end', t, round * 10 + 2)); - }); - return { eventsText: `${events.join('\n')}\n`, rssText: `${rss.map((r) => JSON.stringify(r)).join('\n')}\n` }; -} - -const flat = Array.from({ length: 12 }, (_, i) => 900 * MIB + (i % 2) * MIB); -const leaking = Array.from({ length: 12 }, (_, i) => 900 * MIB + (i < 6 ? 0 : (i - 5) * 20 * MIB)); -const sawtooth = Array.from({ length: 12 }, (_, i) => 900 * MIB + i * 30 * MIB - (i % 3 === 0 ? 40 * MIB : 0)); -const smallCreep = Array.from({ length: 12 }, (_, i) => 900 * MIB + i * 2 * MIB); - -check(median([3, 1, 2]) === 2 && median([4, 1, 3, 2]) === 2.5, 'median of odd and even lists'); -{ - const rows = parseRss('{"wall_ms":1,"rss_kib":10}\nnot json\n{"wall_ms":2,"rss_kib":null}\n'); - check(rows.samples.length === 2 && rows.bad_lines === 1 && rows.samples[1].rss_kib === null, 'rss parser keeps null samples and counts bad lines'); -} -{ - const { eventsText, rssText } = fixture(flat); - const windows = restWindows(eventsText); - check(windows.windows.length === 12 && windows.windows[0].round === 0 && windows.windows[0].rest_ms === 30000, 'rest windows come from soak_rest start/end events'); - const lows = roundLowWaters(windows.windows, parseRss(rssText).samples); - check(lows[3].low_water_kib === flat[3] && lows[3].samples === 31, 'low water is the minimum RSS inside the rest window'); - const verdict = leakVerdict(lows, { warmupRounds: 1 }); - check(verdict.status === 'PASS' && verdict.rounds_analysed === 11, `flat memory passes (${verdict.status} ${verdict.reason})`); - check(/不能证明长期无泄漏/.test(verdict.note), 'a pass still says it cannot prove no long-term leak'); - check(verdict.unit === 'KiB' && Number.isInteger(verdict.first5_median_kib) && Number.isInteger(verdict.last5_median_kib), 'first/last 5 medians are recorded in KiB'); -} -{ - const { eventsText, rssText } = fixture(leaking); - const verdict = leakVerdict(roundLowWaters(restWindows(eventsText).windows, parseRss(rssText).samples), { warmupRounds: 1 }); - check(verdict.status === 'FAIL' && verdict.max_growth_streak >= 5 && verdict.growth_kib > verdict.threshold_kib, `steady growth fails (${JSON.stringify(verdict)})`); - check(verdict.threshold_kib === 50 * MIB, 'threshold is max(50MiB, 5%) — 50MiB for a 900MiB baseline'); -} -{ - // Large growth without 5 consecutive rising rounds is reported but does not fail the gate. - const verdict = leakVerdict(sawtooth.map((low, round) => ({ round, low_water_kib: low, samples: 31, rest_ms: 30000 })), { warmupRounds: 1 }); - check(verdict.status === 'PASS' && verdict.max_growth_streak < 5 && verdict.growth_kib > verdict.threshold_kib && verdict.warnings.length === 1, - `median growth without a 5-round streak is a warning (${JSON.stringify(verdict)})`); -} -{ - const verdict = leakVerdict(smallCreep.map((low, round) => ({ round, low_water_kib: low, samples: 31, rest_ms: 30000 })), { warmupRounds: 1 }); - check(verdict.status === 'PASS' && verdict.max_growth_streak >= 5 && verdict.growth_kib <= verdict.threshold_kib, 'a monotonic creep below the threshold passes with the note'); -} -{ - const big = Array.from({ length: 12 }, (_, i) => 4096 * MIB + i * 60 * MIB); - const verdict = leakVerdict(big.map((low, round) => ({ round, low_water_kib: low, samples: 31, rest_ms: 30000 })), { warmupRounds: 1 }); - check(verdict.threshold_kib > 50 * MIB && verdict.threshold_kib === Math.ceil(0.05 * verdict.first5_median_kib), '5% wins for a large baseline'); -} -{ - const verdict = leakVerdict(flat.slice(0, 10).map((low, round) => ({ round, low_water_kib: low, samples: 31, rest_ms: 30000 })), { warmupRounds: 1 }); - check(verdict.status === 'BLOCKED' && /10/.test(verdict.reason), 'fewer than 10 rounds after warm-up is BLOCKED'); -} -{ - const { eventsText, rssText } = fixture(flat, { dropSamplesInRound: 4 }); - const verdict = leakVerdict(roundLowWaters(restWindows(eventsText).windows, parseRss(rssText).samples), { warmupRounds: 1 }); - check(verdict.status === 'BLOCKED' && /round 4/.test(verdict.reason), `a rest window without RSS samples is BLOCKED (${verdict.reason})`); -} -{ - const { eventsText, rssText } = fixture(flat, { restMs: 10000 }); - const verdict = leakVerdict(roundLowWaters(restWindows(eventsText).windows, parseRss(rssText).samples), { warmupRounds: 1 }); - check(verdict.status === 'FAIL' && /30/.test(verdict.reason), 'a rest window shorter than 30 s violates the contract'); -} -{ - const unmatched = `${event(0, 'start', 1000, 1)}\n${event(1, 'start', 2000, 2)}\n`; - const windows = restWindows(unmatched); - check(windows.windows.length === 0 && windows.errors.length === 2, 'unmatched rest events are errors, not windows'); -} - -// Seal: the memory verdict downgrades the sealed report and never upgrades it. -{ - const { eventsText, rssText } = fixture(leaking); - const base = { schema_version: 1, run_id: 'r', suite: 'soak', status: 'PASS', exit_gate: { checked: true, process_code: 0, timed_out: false, errors: [] } }; - const sealed = sealMemory(structuredClone(base), { eventsText, rssText, warmupRounds: 1 }); - check(sealed.report.status === 'FAIL' && sealed.code === 1, 'leak downgrades PASS to FAIL'); - check(sealed.report.runner_cases?.[0]?.id === 'STB-MEMORY-01' && sealed.report.runner_cases[0].status === 'FAIL', 'runner case STB-MEMORY-01 is recorded'); - check(sealed.report.memory.gpu_memory_kib === null && sealed.report.memory.gpu_memory_status === 'unavailable', 'GPU memory stays null/unavailable'); - check(sealed.report.exit_gate.errors.some((e) => e.startsWith('STB-MEMORY-01')), 'memory verdict reaches exit_gate.errors'); - const blocked = sealMemory(structuredClone(base), { eventsText: '', rssText, warmupRounds: 1 }); - check(blocked.report.status === 'BLOCKED' && blocked.code === 2, 'no rest windows downgrades PASS to BLOCKED'); - const failed = sealMemory({ ...structuredClone(base), status: 'FAIL' }, { ...fixture(flat), warmupRounds: 1 }); - check(failed.report.status === 'FAIL' && failed.code === 1 && failed.report.runner_cases[0].status === 'PASS', 'a clean memory verdict never upgrades a FAIL'); -} - -// CLI seal writes in place; unknown input is a usage error. -{ - const { eventsText, rssText } = fixture(flat); - const dir = path.join(root, 'seal'); - fs.mkdirSync(dir); - fs.writeFileSync(path.join(dir, 'events.jsonl'), eventsText); - fs.writeFileSync(path.join(dir, 'rss.jsonl'), rssText); - fs.writeFileSync(path.join(dir, 'report.json'), JSON.stringify({ schema_version: 1, run_id: 'r', suite: 'soak', status: 'PASS', exit_gate: { checked: true, errors: [] } })); - const run = spawnSync(process.execPath, [script, '--seal', '--report', path.join(dir, 'report.json'), '--rss', path.join(dir, 'rss.jsonl'), - '--events', path.join(dir, 'events.jsonl')], { encoding: 'utf8' }); - const out = JSON.parse(fs.readFileSync(path.join(dir, 'report.json'), 'utf8')); - check(run.status === 0 && out.status === 'PASS' && out.memory.rounds.length === 12, `CLI seal PASS (${run.status} ${run.stderr})`); - const bad = spawnSync(process.execPath, [script, '--seal', '--report', path.join(dir, 'report.json')], { encoding: 'utf8' }); - check(bad.status === 2, 'missing seal arguments exit 2'); - const help = spawnSync(process.execPath, [script, '--help'], { encoding: 'utf8' }); - check(help.status === 0 && /--sample-rss/.test(help.stdout + help.stderr), '--help documents the sampler'); -} - -// Sampler: 1 Hz ps samples of one PID, KiB integers, stops by itself when that PID exits. -{ - const target = spawn('/bin/sleep', ['3'], { stdio: 'ignore' }); - const output = path.join(root, 'sampler.jsonl'); - const started = Date.now(); - const sampler = spawnSync(process.execPath, [script, '--sample-rss', '--pid', String(target.pid), '--output', output, '--interval-ms', '500'], - { encoding: 'utf8', timeout: 15000 }); - const rows = parseRss(fs.readFileSync(output, 'utf8')).samples; - check(sampler.status === 0 && Date.now() - started < 12000, `sampler exits after the PID is gone (${sampler.status} ${sampler.stderr})`); - check(rows.length >= 3 && rows.every((r) => Number.isInteger(r.rss_kib) && r.rss_kib > 0 && Number.isInteger(r.wall_ms)), `sampler writes KiB rows (${rows.length})`); - const refused = spawnSync(process.execPath, [script, '--sample-rss', '--pid', '0', '--output', output], { encoding: 'utf8' }); - check(refused.status === 2, 'sampler refuses a non-positive PID'); -} - -fs.rmSync(root, { recursive: true, force: true }); -console.log(failures === 0 ? 'PASS: playable_soak_metrics_test' : `FAIL: playable_soak_metrics_test (${failures})`); -process.exitCode = failures === 0 ? 0 : 1; diff --git a/script/playable_test.sh b/script/playable_test.sh deleted file mode 100755 index 0844dc3d..00000000 --- a/script/playable_test.sh +++ /dev/null @@ -1,135 +0,0 @@ -#!/usr/bin/env bash -# Public entry for the first playable client gate (INF-02 / REL-01). -# -# Runs N independent client processes through run_client_gate.sh into -# ROOT/run-, writes ROOT/release-manifest.json listing exactly those runs and -# the candidate package hashes, then aggregates ONLY the listed runs. Historical -# PASS reports elsewhere are never scanned. Credentials are asked once (hidden -# TTY prompt) or taken from MT_ACCOUNT/MT_PASSWORD; never from arguments/files. -# Do not add `set -x` to this script. -# -# exit: 0 PASS, 1 FAIL, 2 BLOCKED, 124 a run hit the wall-clock timeout. -set -euo pipefail -set +x -cd "$(dirname "$0")/.." -repo="$PWD" -process_cleanup_grace_seconds=15 -source script/playable_process_cleanup.sh -app="${MT_PLAYABLE_APP:-}" -config="" -output="" -mode="playable" -timeout="900" -allow=0 -repeat=1 -suite="full" -assets="" -serverlist="${MT_PLAYABLE_SERVERLIST:-}" - -usage() { - cat <<'EOF' -用法: script/playable_test.sh --app APP --config CONFIG [选项] - --app APP 导出包路径(候选包;所有 run 必须是同一哈希) - --output DIR 本批次根目录,必须不存在或为空(默认 build/playable/suite-<时间>) - --suite playable|full 必测用例集合,默认 full - --repeat N 独立进程运行次数,默认 1(发布要求见 docs/FIRST-MAC-PLAYABLE-IMPLEMENTATION.md) - --timeout-seconds N 每次墙钟超时,默认 900 - --assets DIR 资源预检根目录(透传 run_client_gate.sh) - --serverlist FILE 测试模式 serverlist 覆盖(透传) - --allow-gameplay 允许专用测试账号发送移动/战斗/拾取/施法请求 -退出码: 0 PASS / 1 FAIL / 2 BLOCKED / 124 超时 -EOF -} -while [ "$#" -gt 0 ]; do - case "$1" in - --app) app="${2:-}"; shift 2 ;; - --config) config="${2:-}"; shift 2 ;; - --output) output="${2:-}"; shift 2 ;; - --suite) suite="${2:-}"; shift 2 ;; - --repeat) repeat="${2:-}"; shift 2 ;; - --timeout-seconds) timeout="${2:-}"; shift 2 ;; - --assets) assets="${2:-}"; shift 2 ;; - --serverlist) serverlist="${2:-}"; shift 2 ;; - --allow-gameplay) allow=1; shift ;; - --help) usage; exit 0 ;; - *) echo "未知选项: $1" >&2; usage >&2; exit 2 ;; - esac -done -if [ -z "$app" ] || [ ! -d "$app" ]; then echo "PLAYABLE SUITE: BLOCKED 需要 --app 指向候选导出包" >&2; exit 2; fi -if [ -z "$config" ] || [ ! -f "$config" ]; then echo "PLAYABLE SUITE: BLOCKED 需要 --config CONFIG" >&2; exit 2; fi -if [ "$suite" != "full" ] && [ "$suite" != "playable" ]; then echo "--suite 只支持 full/playable" >&2; exit 2; fi -if ! [[ "$repeat" =~ ^[1-9][0-9]*$ ]]; then echo "--repeat 必须是正整数" >&2; exit 2; fi - -root="$output" -if [ -z "$root" ]; then root="$repo/build/playable/suite-$(date +%Y%m%d-%H%M%S)-$$"; fi -if [ -e "$root" ] && [ -n "$(find "$root" -mindepth 1 -maxdepth 1 -print -quit)" ]; then - echo "PLAYABLE SUITE: BLOCKED 输出目录已有内容,拒绝混入历史运行:$root" >&2 - exit 2 -fi -mkdir -p "$root" -root="$(cd "$root" && pwd)" - -# Ask once for the whole batch; child runners inherit the environment only. -if [ -z "${MT_ACCOUNT:-}" ] || [ -z "${MT_PASSWORD:-}" ]; then - if [ -t 0 ]; then - if [ -z "${MT_ACCOUNT:-}" ]; then read -r -s -p "测试账号: " MT_ACCOUNT; echo >&2; fi - if [ -z "${MT_PASSWORD:-}" ]; then read -r -s -p "测试密码: " MT_PASSWORD; echo >&2; fi - fi -fi -if [ -z "${MT_ACCOUNT:-}" ] || [ -z "${MT_PASSWORD:-}" ]; then - echo "PLAYABLE SUITE: BLOCKED 缺少 MT_ACCOUNT/MT_PASSWORD,且不是交互终端" >&2 - exit 2 -fi -export MT_ACCOUNT MT_PASSWORD - -# The candidate identity is computed once, before any run, from the same package. -bash script/playable_build_info.sh "$app" >"$root/candidate.json" - -runs_json="[]" -timed_out=0 -for index in $(seq 1 "$repeat"); do - run_dir="$root/run-$index" - args=(--app "$app" --config "$config" --output "$run_dir" --suite "$suite" --mode "$mode" --timeout-seconds "$timeout") - if [ -n "$assets" ]; then args+=(--assets "$assets"); fi - if [ -n "$serverlist" ]; then args+=(--serverlist "$serverlist"); fi - if [ "$allow" -eq 1 ]; then args+=(--allow-gameplay); fi - set +e - bash script/run_client_gate.sh "${args[@]}" &2; exit 124; fi -case "$release_code" in - 0) echo "PLAYABLE SUITE: PASS runs=$repeat root=$root" ;; - 2) echo "PLAYABLE SUITE: BLOCKED root=$root" >&2 ;; - *) echo "PLAYABLE SUITE: FAIL root=$root" >&2; release_code=1 ;; -esac -exit "$release_code" diff --git a/script/port_gate.sh b/script/port_gate.sh index 7ac26005..687546ee 100755 --- a/script/port_gate.sh +++ b/script/port_gate.sh @@ -15,7 +15,7 @@ cd "$(dirname "$0")/.." OUT="${MT_PORT_GATE_DIR:-build-port-gate}" PLATFORMS=("$@") [ ${#PLATFORMS[@]} -eq 0 ] && PLATFORMS=(macos android ios windows linux) -PORT_ONLY=(-DMTGODOT_BUILD_EXTENSION=OFF -DMTGODOT_BUILD_PORT=ON) +PORT_ONLY=(-DMT_BUILD_NATIVE_RENDER=OFF) declare -a RESULTS=() run() { # name, build dir, configure args... diff --git a/script/python_game_render_test.sh b/script/python_game_render_test.sh deleted file mode 100755 index d5555afc..00000000 --- a/script/python_game_render_test.sh +++ /dev/null @@ -1,63 +0,0 @@ -#!/usr/bin/env bash -# 2V2-e: the ported client (system.py in Godot) logs in, enters the game and draws RenderGame's -# commands; project/python_game_render_test.gd takes the screenshot and writes report.json. -# -# script/python_game_render_test.sh offline: port_fake_login_server on loopback -# MT_FAKE_MOB_COUNT=24 MT_PROFILE_FRAME=1 MT_PROFILE_COMBAT=1 script/python_game_render_test.sh -# offline crowd sample (1..64 dogs; default 1) -# script/python_game_render_test.sh --live live: MT_LIVE_HOST / MT_LIVE_LOGIN / MT_LIVE_PASSWORD -# (+ MT_LIVE_AUTH_PORT / MT_LIVE_GAME_PORT / MT_LIVE_SLOT) -# -# Credentials are only ever read from the caller's environment; nothing here prints them. Do not add -# `set -x`. Needs a windowed Godot (a headless run draws nothing and skips the screenshot checks). -# exit: 0 PASS, 1 FAIL, 2 precondition missing. -set -euo pipefail -set +x - -cd "$(dirname "$0")/.." -repo="$PWD" -godot_bin="${GODOT:-godot}" -output="${MT_RENDER_OUTPUT:-$repo/build/rendering/python-game-$(date +%Y%m%d-%H%M%S)-$$}" -mkdir -p "$output" -export MT_RENDER_OUTPUT="$output" - -server_pid="" -cleanup() { - if [ -n "$server_pid" ]; then - kill -TERM "$server_pid" 2>/dev/null || true - wait "$server_pid" 2>/dev/null || true - fi -} -trap cleanup EXIT - -if [ "${1:-}" = "--live" ]; then - if [ -z "${MT_LIVE_HOST:-}" ] || [ -z "${MT_LIVE_LOGIN:-}" ] || [ -z "${MT_LIVE_PASSWORD:-}" ]; then - echo "python_game_render_test: --live needs MT_LIVE_HOST, MT_LIVE_LOGIN, MT_LIVE_PASSWORD" >&2 - exit 2 - fi -else - server="$repo/build/extension/src/port/port_fake_login_server" - if [ ! -x "$server" ]; then - echo "python_game_render_test: build port_fake_login_server first ($server)" >&2 - exit 2 - fi - fifo="$output/server.ports" - "$server" > "$fifo" 2> "$output/server.log" & - server_pid=$! - for _ in $(seq 50); do - [ -s "$fifo" ] && break - sleep 0.1 - done - read -r word auth_port game_port < "$fifo" || true - if [ "${word:-}" != "ports" ]; then - echo "python_game_render_test: the fake server did not start (see $output/server.log)" >&2 - exit 2 - fi - export MT_LIVE_HOST=127.0.0.1 MT_LIVE_LOGIN=fakeuser MT_LIVE_PASSWORD=fakepass - export MT_LIVE_AUTH_PORT="$auth_port" MT_LIVE_GAME_PORT="$game_port" MT_LIVE_SLOT=0 -fi - -status=0 -"$godot_bin" --path "$repo/project" --script res://python_game_render_test.gd > "$output/godot.log" 2>&1 || status=$? -grep -E "^(ok|FAIL):|python_game_render_test:" "$output/godot.log" || true -exit "$status" diff --git a/script/rendering_batch_test.sh b/script/rendering_batch_test.sh deleted file mode 100644 index ad8eecec..00000000 --- a/script/rendering_batch_test.sh +++ /dev/null @@ -1,34 +0,0 @@ -#!/usr/bin/env bash -# Repeatable, non-network regression sweep; logs retain failures and warnings. -set -uo pipefail -cd "$(dirname "$0")/.." -run_dir="$(pwd)/build/rendering/batch-$(date +%Y%m%d-%H%M%S)-$$" -mkdir -p "$run_dir" -failed=0 -run_test() { - local mode="$1" test_script="$2" log="$run_dir/$1-${2%.gd}.log" - local options=(--path project) - if [ "$mode" = headless ]; then options+=(--headless); fi - "${GODOT:-godot}" "${options[@]}" --script "$test_script" --quit-after 1800 >"$log" 2>&1 - local status=$? - if [ "$status" -ne 0 ] || grep -qE 'SCRIPT ERROR|Parse Error|^FAIL:|check\(s\) failed' "$log" || ! grep -qE 'PASS|failures=0' "$log"; then - echo "FAIL $mode $test_script (exit=$status): $log" - failed=$((failed + 1)) - else - echo "PASS $mode $test_script" - fi - if grep -q 'leaked at exit' "$log"; then echo "WARN resource leak: $log"; fi -} -for test_script in animation_cache_test.gd animation_blend_test.gd target_effect_test.gd effect_space_test.gd effect_faces_test.gd motion_effect_anchor_test.gd mob_winding_test.gd water_reference_test.gd effect_texture_animation_test.gd dds_lifecycle_test.gd effect_color_operation_test.gd effect_color_test.gd effect_scale_test.gd effect_rotation_test.gd effect_surface_test.gd effect_emission_test.gd effect_render_regression_test.gd effect_playback_test.gd fx_test.gd skill_fx_test.gd player_skill_test.gd skill_test.gd damage_effect_test.gd combat_fx_test.gd app_flow_lifecycle_test.gd playable_harness_test.gd playable_adapter_test.gd playable_combat_test.gd gamescene_test.gd equip_model_test.gd equip_rules_test.gd race_motion_assembly_test.gd character_winding_test.gd mob_view_test.gd net_world_vis_test.gd forest_viewpoints_test.gd playable_metrics_test.gd; do - run_test headless "$test_script" -done -for test_script in effect_faces_test.gd effect_lie_test.gd effect_texture_animation_test.gd effect_color_operation_test.gd effect_surface_test.gd effect_emission_test.gd effect_rotation_test.gd model_render_test.gd gpu_lod_attachment_test.gd gpu_pose_bounds_test.gd forest_mob_render_test.gd; do - run_test metal "$test_script" -done -for mode in headless metal; do - MT_TREE_OUTPUT="$run_dir/native-trees-$mode" run_test "$mode" native_tree_test.gd - run_test "$mode" effect_random_rotation_test.gd - run_test "$mode" fly_target_bounds_test.gd -done -echo "BATCH failures=$failed logs=$run_dir" -test "$failed" -eq 0 diff --git a/script/rendering_test.sh b/script/rendering_test.sh deleted file mode 100644 index acfb39be..00000000 --- a/script/rendering_test.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env bash -# Local macOS visual tests must load the library that was just built. -set -euo pipefail -cd "$(dirname "$0")/.." -cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -cmake --build build --target mtgodot -j8 -cp project/bin/libmtgodot.macos.template_release.dylib project/bin/libmtgodot.macos.template_debug.dylib -codesign --force --sign - project/bin/libmtgodot.macos.template_debug.dylib -shasum -a 256 project/bin/libmtgodot.macos.template_debug.dylib -"${GODOT:-godot}" --headless --path project --script character_winding_test.gd -"${GODOT:-godot}" --path project --script char_select_visual_test.gd -- "$@" diff --git a/script/run_40250_asset_gate.sh b/script/run_40250_asset_gate.sh deleted file mode 100755 index 985e7352..00000000 --- a/script/run_40250_asset_gate.sh +++ /dev/null @@ -1,64 +0,0 @@ -#!/usr/bin/env bash -# 40250 asset gate (docs/PORT-PLAN.md 2D steps 3-4): with MT_ASSETS_STRICT=1, verify the provenance manifest -# (audit/assets/40250-client.json), then run the native pack/proto tests and the Godot tests that read the -# 40250 client. Missing assets, a manifest mismatch, a skipped test or a failed one all fail the gate; nothing is -# reported as PASS without having run. -# -# MT_40250_CLIENT=<.../Server Client TMP4/Client> script/run_40250_asset_gate.sh -# -# Uses the build tree $MT_BUILD_DIR (default build/, configured with BUILD_TESTING=ON) and $GODOT (default godot). -set -uo pipefail -cd "$(dirname "$0")/.." -REPO="$PWD" - -export MT_ASSETS_STRICT=1 -CLIENT="${MT_40250_CLIENT:-$REPO/../40250/Server Client TMP4/Client}" -BUILD="${MT_BUILD_DIR:-build}" -GODOT="${GODOT:-godot}" -LOG="${MT_ASSET_GATE_LOG:-$BUILD/40250-asset-gate.log}" -fail=0 -: >"$LOG" 2>/dev/null || LOG=/dev/null - -step() { # name, command... - local name="$1"; shift - if "$@" >>"$LOG" 2>&1; then - echo "PASS $name" - else - echo "FAIL $name (see $LOG)" - fail=1 - fi -} - -if [ ! -f "$CLIENT/pack/Index" ]; then - echo "FAIL no 40250 client at '$CLIENT' (set MT_40250_CLIENT)" - exit 1 -fi -CLIENT="$(cd "$CLIENT" && pwd)" -export MT_40250_CLIENT="$CLIENT" - -step "manifest audit/assets/40250-client.json" python3 tools/asset_manifest.py verify "$CLIENT" -step "40250 root scripts in root.epk" python3 script/verify_root_pack.py --client "$CLIENT" - -native() { - cmake -S . -B "$BUILD" -DBUILD_TESTING=ON "-DMT_40250_CLIENT=$CLIENT" \ - && cmake --build "$BUILD" --target port_eterpack_test proto_test proto_item_layout_test mtgodot -j 8 \ - && ctest --test-dir "$BUILD" -R '^(port\.eterpack|proto\.)' --output-on-failure --no-tests=error -} # under MT_ASSETS_STRICT=1 the tests exit 1, not the ctest skip code 77, when the client is missing -step "native pack/proto tests" native - -godot_test() { # script: runs headless; a "(skip" line is a failure under the gate - local out - out="$(cd project && "$GODOT" --headless --path . -s "$1" 2>&1)" - local rc=$? - printf '%s\n' "$out" - [ $rc -eq 0 ] && ! grep -Eiq '(^SKIP:|\(skip)' <<<"$out" && grep -q "^PASS" <<<"$out" -} -for t in python_host_test.gd audio_driver_test.gd; do - step "godot $t" godot_test "$t" -done - -if [ $fail -ne 0 ]; then - echo "40250 asset gate: FAIL" - exit 1 -fi -echo "40250 asset gate: PASS" diff --git a/script/run_client_gate.sh b/script/run_client_gate.sh deleted file mode 100755 index 45a5bbfb..00000000 --- a/script/run_client_gate.sh +++ /dev/null @@ -1,311 +0,0 @@ -#!/usr/bin/env bash -# INF-02: run ONE exported client process and seal its report after it exits. -# -# - Owns exactly one child PID (plus its own log redactor); never pkills other -# clients, never touches routes/firewall/system caches. -# - Credentials only from MT_ACCOUNT/MT_PASSWORD or a hidden TTY prompt; never -# from arguments or files. Do not add `set -x` to this script. -# - The client writes client-report.json; this script writes the final -# report.json only after the real process exit and the full-log gate. -# - suite soak (STB-01): also samples RSS of the child PID once per second into -# rss.jsonl, seals STB-MEMORY-01 into report.json (can only downgrade), and when -# soak.faults is confirmed runs script/playable_fault_proxy.mjs on 127.0.0.1 for -# this client's connection only (for every suite, since server.* then points at the -# proxy). Sampler and proxy are this script's own children. -# -# exit: 0 PASS, 1 assert/exit-gate FAIL, 2 config/precondition BLOCKED, 124 wall-clock timeout. -set -euo pipefail -set +x - -cd "$(dirname "$0")/.." -repo="$PWD" -app_path="${MT_PLAYABLE_APP:-}" -mode="playable" -suite="playable" -config_path="" -assets_root="" -serverlist="${MT_PLAYABLE_SERVERLIST:-}" -output_dir="" -timeout_seconds="${MT_PLAYABLE_TIMEOUT_SECONDS:-900}" -allow_gameplay=0 -required_arch="${MT_PLAYABLE_REQUIRED_ARCH:-arm64}" -godot_bin="${MT_GODOT:-godot}" -kill_grace_seconds=10 -source script/playable_process_cleanup.sh - -usage() { - cat <<'EOF' -用法: script/run_client_gate.sh --app APP --config CONFIG [选项] -选项: - --output DIR 本次运行目录,必须不存在或为空(默认 build/playable/run-<时间>-) - --suite playable|full|soak 必测用例集合,默认 playable;soak 需 --timeout-seconds >= soak.duration_seconds+900 - --mode MODE 包内 MT_TEST_MODE,默认 playable - --assets DIR 资源预检根目录(默认包内 Contents/Resources/assets) - --serverlist FILE 测试模式 serverlist 覆盖;预检与客户端使用同一文件 - --timeout-seconds N 墙钟超时,默认 900;超时 TERM,10 秒后 KILL - --allow-gameplay 允许专用测试账号发送移动/战斗/拾取/施法请求 -环境变量: - MT_ACCOUNT / MT_PASSWORD 测试账号(未设置且 stdin 是终端时隐藏输入;否则返回 2) - MT_PLAYABLE_REQUIRED_ARCH 包必须包含的架构,默认 arm64 -退出码: 0 PASS / 1 FAIL / 2 BLOCKED(配置或前置条件)/ 124 墙钟超时 -EOF -} - -while [ "$#" -gt 0 ]; do - case "$1" in - --app) app_path="${2:-}"; shift 2 ;; - --config) config_path="${2:-}"; shift 2 ;; - --output) output_dir="${2:-}"; shift 2 ;; - --suite) suite="${2:-}"; shift 2 ;; - --mode) mode="${2:-}"; shift 2 ;; - --assets) assets_root="${2:-}"; shift 2 ;; - --serverlist) serverlist="${2:-}"; shift 2 ;; - --timeout-seconds) timeout_seconds="${2:-}"; shift 2 ;; - --allow-gameplay) allow_gameplay=1; shift ;; - --help) usage; exit 0 ;; - *) echo "未知选项: $1" >&2; usage >&2; exit 2 ;; - esac -done - -output_ready=0 -blocked() { - echo "PLAYABLE GATE: BLOCKED $*" >&2 - if [ "$output_ready" -eq 1 ]; then echo "BLOCKED $*" >>"$output_dir/gate.log"; fi - exit 2 -} - -[ -n "$app_path" ] || blocked "需要 --app" -[ -n "$config_path" ] && [ -f "$config_path" ] || blocked "配置不存在;不会启动客户端" -[ "$suite" = "playable" ] || [ "$suite" = "full" ] || [ "$suite" = "soak" ] || blocked "--suite 只支持 playable/full/soak" -[[ "$timeout_seconds" =~ ^[1-9][0-9]*$ ]] || blocked "--timeout-seconds 必须是正整数" -config_path="$(cd "$(dirname "$config_path")" && pwd)/$(basename "$config_path")" -engine="$app_path/Contents/MacOS/mtgodot-poc" -[ -x "$engine" ] || blocked "找不到可执行包:$app_path" -if [ -n "$serverlist" ]; then - [ -f "$serverlist" ] || blocked "serverlist 不存在" - serverlist="$(cd "$(dirname "$serverlist")" && pwd)/$(basename "$serverlist")" -fi - -if [ -z "$output_dir" ]; then - output_dir="$repo/build/playable/run-$(date +%Y%m%d-%H%M%S)-$$" -fi -if [ -e "$output_dir" ] && [ -n "$(find "$output_dir" -mindepth 1 -maxdepth 1 -print -quit)" ]; then - blocked "输出目录已有内容,拒绝复用历史报告:$output_dir" -fi -mkdir -p "$output_dir" -output_dir="$(cd "$output_dir" && pwd)" -output_ready=1 - -# 2. package identity: signature and architecture before anything runs. -if ! codesign --verify --strict "$app_path" >/dev/null 2>&1; then - blocked "签名校验失败:codesign --verify --strict $app_path" -fi -archs="$(lipo -archs "$engine" 2>/dev/null || true)" -case " $archs " in - *" $required_arch "*) ;; - *) blocked "包架构 [$archs] 不含 $required_arch" ;; -esac -run_id="$(basename "$output_dir")-$(od -An -N4 -tx4 /dev/urandom | tr -d ' ')" -log="$output_dir/client.log" -client_report="$output_dir/client-report.json" -final_report="$output_dir/report.json" -events="$output_dir/events.jsonl" - -if [ -z "$assets_root" ]; then - if [ -d "$app_path/Contents/Resources/assets" ]; then - assets_root="$app_path/Contents/Resources/assets" - else - assets_root="$repo/assets" - fi -fi - -# 3. config + address + resource preconditions; resolves the address the client will use. -if ! env MT_PLAYABLE_VALIDATE_CONFIG="$config_path" MT_PLAYABLE_SUITE="$suite" \ - MT_PLAYABLE_ASSETS="$assets_root" MT_PLAYABLE_SERVERLIST="$serverlist" \ - MT_PLAYABLE_GATE_OUTPUT="$output_dir" \ - "$godot_bin" --headless --path project --script playable_config_gate.gd >"$output_dir/config.log" 2>&1; then - blocked "配置/资源预检失败;不会启动客户端:$output_dir/config.log" -fi - -# The config gate validated any soak block (for every suite); read only the fields the runner needs. -read -r soak_duration soak_warmup soak_min_rounds fault_status up_auth_host up_auth_port up_game_host up_game_port <<<"$(node -e ' -const c = JSON.parse(require("node:fs").readFileSync(process.argv[1], "utf8")); -const s = c.soak || {}; const f = s.faults || {}; const u = f.upstream || {}; -process.stdout.write([s.duration_seconds || 0, s.warmup_rounds ?? 1, s.min_rounds || 10, f.status || "unconfirmed", u.auth_host || "-", u.auth_port || 0, u.game_host || "-", u.game_port || 0].join(" ")); -' "$config_path")" -if [ "$suite" = "soak" ]; then - [ "$timeout_seconds" -ge $((soak_duration + 900)) ] || blocked "soak 需要 --timeout-seconds >= soak.duration_seconds + 900(当前 ${timeout_seconds})" -fi - -# Credentials: env or hidden prompt only. -if [ -z "${MT_ACCOUNT:-}" ] || [ -z "${MT_PASSWORD:-}" ]; then - if [ -t 0 ]; then - if [ -z "${MT_ACCOUNT:-}" ]; then read -r -s -p "测试账号: " MT_ACCOUNT; echo >&2; fi - if [ -z "${MT_PASSWORD:-}" ]; then read -r -s -p "测试密码: " MT_PASSWORD; echo >&2; fi - fi -fi -[ -n "${MT_ACCOUNT:-}" ] && [ -n "${MT_PASSWORD:-}" ] || blocked "缺少 MT_ACCOUNT/MT_PASSWORD,且不是交互终端" -export MT_ACCOUNT MT_PASSWORD - -read_address() { - node -e ' -const a = JSON.parse(require("node:fs").readFileSync(process.argv[1], "utf8")); -process.stdout.write([a.auth_host, a.auth_port, a.game_host, a.game_port].join(" ")); -' "$output_dir/server-address.json" -} -read -r auth_host auth_port game_host game_port <<<"$(read_address)" - -proxy_pid="" -stop_proxy() { - if [ -n "$proxy_pid" ]; then - kill -TERM "$proxy_pid" 2>/dev/null || true - wait "$proxy_pid" 2>/dev/null || true - proxy_pid="" - fi -} -if [ "$fault_status" = "confirmed" ]; then - # Loopback proxy in front of the test server for this client only. server.* points at it, so - # playable exit runs sharing the scenario go through it too; only the soak client requests faults. - for endpoint in "$up_auth_host:$up_auth_port" "$up_game_host:$up_game_port"; do - if ! nc -z -G 3 "${endpoint%:*}" "${endpoint##*:}" >/dev/null 2>&1; then - blocked "故障代理上游不可达:$endpoint" - fi - done - routes=(--route "$auth_port=$up_auth_host:$up_auth_port") - if [ "$game_port" != "$auth_port" ]; then - routes+=(--route "$game_port=$up_game_host:$up_game_port") - elif [ "$up_game_host:$up_game_port" != "$up_auth_host:$up_auth_port" ]; then - blocked "auth/game 共用代理端口,但上游地址不同" - fi - node script/playable_fault_proxy.mjs "${routes[@]}" --events "$events" --log "$output_dir/fault-proxy.jsonl" \ - --run-id "$run_id" >"$output_dir/fault-proxy.log" 2>&1 /dev/null; do - if ! kill -0 "$proxy_pid" 2>/dev/null || [ "$proxy_wait" -ge 50 ]; then - blocked "故障代理未能在 127.0.0.1 监听:$output_dir/fault-proxy.log" - fi - sleep 0.1 - proxy_wait=$((proxy_wait + 1)) - done - echo "FAULT PROXY pid=$proxy_pid listen=127.0.0.1:$auth_port,$game_port" >>"$output_dir/gate.log" -fi -for endpoint in "$auth_host:$auth_port" "$game_host:$game_port"; do - if ! nc -z -G 3 "${endpoint%:*}" "${endpoint##*:}" >/dev/null 2>&1; then - blocked "服务器端口不可达:$endpoint" - fi -done -char_slot="$(node -e 'process.stdout.write(String(JSON.parse(require("node:fs").readFileSync(process.argv[1],"utf8")).character_slot))' "$config_path")" - -# 1. identity of this run. -bash script/playable_build_info.sh "$app_path" >"$output_dir/build.json" -build_field() { node -e 'process.stdout.write(String(JSON.parse(require("node:fs").readFileSync(process.argv[1],"utf8"))[process.argv[2]]))' "$output_dir/build.json" "$1"; } - -export MT_TEST_MODE="$mode" -export MT_PLAYABLE_SUITE="$suite" -export MT_PLAYABLE_CONFIG="$config_path" -export MT_PLAYABLE_RUN_ID="$run_id" -export MT_PLAYABLE_SERVERLIST="$serverlist" -export MT_TEST_REPORT="$client_report" -export MT_TEST_EVENTS="$events" -export MT_TEST_OUTPUT="$output_dir" -export MT_PROTOCOL="classic" -export MT_AUTOLOGIN=1 -export MT_CHAR_SLOT="$char_slot" -export MT_BUILD_ENGINE_SHA256="$(build_field engine_sha256)" -export MT_BUILD_EXTENSION_SHA256="$(build_field extension_sha256)" -export MT_BUILD_PCK_SHA256="$(build_field pck_sha256)" -export MT_BUILD_ARCH="$(build_field arch)" -export MT_PLAYABLE_ALLOW_GAMEPLAY="$allow_gameplay" - -# 4/5. child stdout+stderr -> FIFO -> redactor -> client.log. The child PID is -# the engine itself (redirections are applied before exec), so `wait` returns -# its real status regardless of the log pipe. -fifo="$output_dir/.client.fifo" -mkfifo "$fifo" -started_ms="$(($(date +%s) * 1000))" -set +e -node script/redact_stream.mjs <"$fifo" >"$log" & -redactor_pid=$! -env -u MT_ASSETS "$engine" >"$fifo" 2>&1 >"$output_dir/gate.log" 2>&1 & - sampler_pid=$! -fi -SECONDS=0 -timed_out=0 -while kill -0 "$child_pid" 2>/dev/null; do - if [ "$SECONDS" -ge "$timeout_seconds" ]; then - timed_out=1 - kill -TERM "$child_pid" 2>/dev/null - grace=0 - while kill -0 "$child_pid" 2>/dev/null && [ "$grace" -lt "$kill_grace_seconds" ]; do - sleep 1 - grace=$((grace + 1)) - done - killed=0 - if kill -0 "$child_pid" 2>/dev/null; then kill -KILL "$child_pid" 2>/dev/null; killed=1; fi - echo "TIMEOUT term_grace_s=$grace killed=$killed" >>"$output_dir/gate.log" - break - fi - sleep 1 -done -wait "$child_pid" -process_code=$? -child_pid="" -# The redactor ends at EOF; bound the wait in case a grandchild kept the FIFO open. -redactor_wait=0 -while kill -0 "$redactor_pid" 2>/dev/null && [ "$redactor_wait" -lt 10 ]; do - sleep 1 - redactor_wait=$((redactor_wait + 1)) -done -if kill -0 "$redactor_pid" 2>/dev/null; then - kill -TERM "$redactor_pid" 2>/dev/null - echo "PLAYABLE GATE: log redactor did not reach EOF (FIFO held open)" >>"$output_dir/gate.log" -fi -wait "$redactor_pid" -redactor_code=$? -redactor_pid="" -rm -f "$fifo" -if [ -n "$sampler_pid" ]; then - # The sampler stops by itself once the child is gone; bound the wait anyway. - sampler_wait=0 - while kill -0 "$sampler_pid" 2>/dev/null && [ "$sampler_wait" -lt 10 ]; do - sleep 1 - sampler_wait=$((sampler_wait + 1)) - done - if kill -0 "$sampler_pid" 2>/dev/null; then kill -TERM "$sampler_pid" 2>/dev/null; fi - wait "$sampler_pid" - sampler_pid="" -fi -stop_proxy - -node script/validate_playable_report.mjs --report "$client_report" --output "$final_report" \ - --log "$log" --process-code "$process_code" --timed-out "$timed_out" --run-id "$run_id" \ - --suite "$suite" --required-cases "$output_dir/required-cases.json" --build "$output_dir/build.json" \ - --events "$events" --started-ms "$started_ms" --redactor-code "$redactor_code" --require-pass -gate_status=$? -if [ "$suite" = "soak" ]; then - node script/playable_soak_metrics.mjs --seal --report "$final_report" --rss "$output_dir/rss.jsonl" --events "$events" \ - --warmup-rounds "$soak_warmup" --min-rounds "$soak_min_rounds" - memory_status=$? - if [ "$gate_status" -ne 0 ] && [ "$gate_status" -ne 2 ]; then gate_status=1 - elif [ "$memory_status" -ne 0 ] && [ "$memory_status" -ne 2 ]; then gate_status=1 - elif [ "$gate_status" -eq 2 ] || [ "$memory_status" -eq 2 ]; then gate_status=2 - fi -fi -set -e - -if [ "$timed_out" -eq 1 ]; then - echo "PLAYABLE GATE: TIMEOUT raw_exit=$process_code report=$final_report log=$log" >&2 - exit 124 -fi -case "$gate_status" in - 0) echo "PLAYABLE GATE: PASS run_id=$run_id report=$final_report" ;; - 2) echo "PLAYABLE GATE: BLOCKED raw_exit=$process_code report=$final_report" >&2 ;; - *) echo "PLAYABLE GATE: FAIL raw_exit=$process_code report=$final_report log=$log" >&2; gate_status=1 ;; -esac -exit "$gate_status" diff --git a/script/stage_tree_geometry.mjs b/script/stage_tree_geometry.mjs deleted file mode 100644 index dca22af0..00000000 --- a/script/stage_tree_geometry.mjs +++ /dev/null @@ -1,44 +0,0 @@ -// Stage already-extracted, hash-matched native trees. Never manufacture geometry. -import fs from 'node:fs'; -import path from 'node:path'; -import crypto from 'node:crypto'; -const [source, assets] = process.argv.slice(2); -if (!source || !assets) throw Error('Usage: node script/stage_tree_geometry.mjs CONVERTED_ROOT ASSETS_ROOT'); -const hash = b => crypto.createHash('sha256').update(b).digest('hex'); -const index = JSON.parse(fs.readFileSync(path.join(source, 'asset-index.json'))); -const records = []; -for (const item of Object.values(index.assets)) { - if (item.kind !== 'tree') continue; - if (item.status !== 'converted' || item.build.native_extractor !== 'spt_extract.exe' || - item.build.coordinate_policy !== 'raw_speedtree_z_up_centimeters_to_godot_x_z_neg_y_meters') throw Error(`Unverified tree: ${item.id}`); - const raw = item.source.raw; - const spt = fs.readFileSync(path.join(assets, raw.source_physical)); - const bytes = fs.readFileSync(path.join(source, item.output)); - if (hash(spt) !== raw.source_sha256 || hash(bytes) !== item.output_sha256) throw Error(`Hash mismatch: ${item.id}`); - if (bytes.readUInt32LE(0) !== 0x46546c67 || bytes.readUInt32LE(4) !== 2 || bytes.readUInt32LE(8) !== bytes.length) throw Error('Invalid GLB'); - const gltf = JSON.parse(bytes.subarray(20, 20 + bytes.readUInt32LE(12)).toString()); - // Runtime loads mesh zero directly: reject transforms, external data and scenes - // that would need flattening instead of silently dropping their transforms. - if (gltf.nodes.length !== 1 || gltf.nodes[0].mesh !== 0 || gltf.meshes.length !== 1 || - ['matrix', 'translation', 'rotation', 'scale', 'children'].some(k => k in gltf.nodes[0]) || - gltf.buffers.some(b => b.uri) || (gltf.images ?? []).some(i => i.uri)) throw Error(`Unsupported scene: ${item.id}`); - records.push({bytes, key: raw.source_sha256, record: { - source: raw.source_physical, source_sha256: raw.source_sha256, - glb: `${raw.source_sha256}.glb`, glb_sha256: item.output_sha256, - native_extractor: item.build.native_extractor, extractor_runs: item.build.extractor_runs, - primitives: gltf.meshes[0].primitives.length, leaves: 'static_extracted_cards', wind: 'not_baked' - }}); -} -if (!records.length) throw Error('No native trees found'); -const dest = path.join(assets, 'TreeGeometry'); -// Validate the whole set before staging. Do not overwrite unrelated asset files. -fs.mkdirSync(dest, {recursive: true}); -const trees = {}; -for (const {bytes, key, record} of records) { - const target = path.join(dest, record.glb); - if (fs.existsSync(target) && hash(fs.readFileSync(target)) !== record.glb_sha256) throw Error(`Conflicting existing GLB: ${target}`); - if (!fs.existsSync(target)) fs.writeFileSync(target, bytes); - trees[key] = record; -} -fs.writeFileSync(path.join(dest, 'manifest.json'), JSON.stringify({schema_version: 1, coordinates: 'godot_y_up_meters', trees}, null, 2) + '\n'); -console.log(`Staged ${records.length} verified native tree species into ${dest}`); diff --git a/script/validate_playable_report.mjs b/script/validate_playable_report.mjs deleted file mode 100755 index ff02b301..00000000 --- a/script/validate_playable_report.mjs +++ /dev/null @@ -1,336 +0,0 @@ -#!/usr/bin/env node -/* INF-02 / REL-01 report gate. - * - * Run mode (called by run_client_gate.sh after the child exited): - * --report client-report.json --output report.json --log client.log - * --process-code N --timed-out 0|1 --run-id ID --suite S - * --required-cases required-cases.json --build build.json --events events.jsonl - * --started-ms MS [--redactor-code N] [--require-pass] - * Seals the final report. The client can never mark a run PASS by itself. - * - * Release mode: - * --release-dir DIR reads DIR/release-manifest.json, checks every explicitly - * listed sealed run (same candidate hashes, PASS, counts), writes - * DIR/release-report.json. History is never scanned. - * - * exit: 0 PASS, 1 FAIL, 2 BLOCKED (only BLOCKED/SKIP findings, clean process+log). - * Credential literals are read from MT_ACCOUNT/MT_PASSWORD and never printed. */ -import fs from 'node:fs'; -import path from 'node:path'; - -const VALID = new Set(['PASS', 'FAIL', 'BLOCKED', 'SKIP']); -const SUITES = new Set(['playable', 'full', 'soak', 'forest_render']); -// Same rule as script/package_render_test.sh, plus parse errors. No RID allow-list. -const ERROR_RE = /SCRIPT ERROR|Parse Error|^ERROR:|leaked at exit|shaders of type .* were never freed/; -const EVENT_KEYS = ['monotonic_us', 'run_id', 'case_id', 'connection_epoch', 'stage', 'kind', 'actor_vid', 'target_vid', 'payload']; -const HASH_KEYS = ['engine_sha256', 'extension_sha256', 'pck_sha256']; -const HEX64 = /^[0-9a-f]{64}$/; -const SECRET_MIN_SCAN = 4; -const MTIME_SLACK_MS = 1000; - -function usage() { - console.error('usage: validate_playable_report.mjs --report PATH --output PATH --log PATH --process-code N --run-id ID ' - + '--suite S --required-cases PATH --build PATH --events PATH --started-ms MS [--timed-out 0|1] [--require-pass]\n' - + ' validate_playable_report.mjs --release-dir DIR'); -} - -function argsOf(argv) { - const out = { require_pass: false }; - for (let i = 0; i < argv.length; i += 1) { - const arg = argv[i]; - if (arg === '--require-pass') out.require_pass = true; - else if (arg === '--help') out.help = true; - else if (arg.startsWith('--')) { - const key = arg.slice(2).replaceAll('-', '_'); - if (i + 1 >= argv.length) throw new Error(`missing value for ${arg}`); - out[key] = argv[++i]; - } else throw new Error(`unknown argument: ${arg}`); - } - if (out.help) return out; - if (!out.release_dir) { - for (const key of ['report', 'output', 'log', 'process_code', 'run_id', 'suite', 'required_cases', 'build', 'events', 'started_ms']) { - if (out[key] === undefined) throw new Error(`--${key.replaceAll('_', '-')} is required`); - } - } - return out; -} - -function readJson(file) { - try { - const value = JSON.parse(fs.readFileSync(file, 'utf8')); - if (!isObject(value)) return { __read_error: `${path.basename(file)}: root must be an object` }; - return value; - } - catch (error) { return { __read_error: `${path.basename(file)}: ${error.code || 'invalid JSON'}` }; } -} - -function isObject(value) { return value !== null && typeof value === 'object' && !Array.isArray(value); } - -function secretLiterals() { - return ['MT_ACCOUNT', 'MT_PASSWORD'].map((name) => process.env[name] || '').filter((v) => v.length >= SECRET_MIN_SCAN); -} - -function scanSecrets(label, text, errors) { - for (const literal of secretLiterals()) { - if (text.includes(literal)) errors.fail.push(`${label} contains a credential literal`); - } -} - -class Findings { - constructor() { this.fail = []; this.blocked = []; } - get all() { return [...new Set([...this.fail, ...this.blocked])]; } -} - -function checkBuild(build, expected, prefix, errors) { - for (const key of HASH_KEYS) { - if (typeof build?.[key] !== 'string' || !HEX64.test(build[key])) errors.fail.push(`${prefix}build.${key} is missing or not sha256`); - else if (expected && build[key] !== expected[key]) errors.fail.push(`${prefix}build.${key} differs from the candidate`); - } - if (typeof build?.arch !== 'string' || build.arch.trim() === '') errors.fail.push(`${prefix}build.arch is missing`); - else if (expected && build.arch !== expected.arch) errors.fail.push(`${prefix}build.arch differs from the candidate`); -} - -function validateReport(report, options, errors) { - if (report.__read_error) { - errors.fail.push(`client report unreadable: ${report.__read_error}`); - return; - } - if (!report || typeof report !== 'object' || Array.isArray(report)) { - errors.fail.push('report root must be an object'); - return; - } - if (report.schema_version !== 1) errors.fail.push('schema_version must be 1'); - if (typeof report.run_id !== 'string' || report.run_id.trim() === '') errors.fail.push('run_id is missing'); - else if (report.run_id !== options.run_id) errors.fail.push('run_id does not match this run (stale or foreign report)'); - if (report.suite !== options.suite) errors.fail.push(`suite ${report.suite} does not match ${options.suite}`); - if (!VALID.has(report.status)) errors.fail.push(`invalid report status: ${report.status}`); - const expectedBuild = readJson(options.build); - if (expectedBuild.__read_error) errors.fail.push(`build.json unreadable: ${expectedBuild.__read_error}`); - checkBuild(report.build, expectedBuild.__read_error ? null : expectedBuild, '', errors); - - const required = readJson(options.required_cases); - const requiredIds = Array.isArray(required?.cases) ? required.cases : null; - if (!requiredIds || requiredIds.length === 0) errors.fail.push('required-cases.json is missing or empty'); - else if (required.suite !== options.suite) errors.fail.push('required-cases.json suite mismatch'); - const cases = Array.isArray(report.cases) ? report.cases : []; - if (cases.length === 0) errors.fail.push('cases must be non-empty'); - const seen = new Map(); - let passed = 0; - for (const item of cases) { - if (!item || typeof item !== 'object' || typeof item.id !== 'string' || item.id.trim() === '') { - errors.fail.push('case id is missing'); - continue; - } - if (seen.has(item.id)) errors.fail.push(`${item.id}: duplicated case`); - seen.set(item.id, item); - if (!VALID.has(item.status)) errors.fail.push(`${item.id}: invalid status`); - if (item.status === 'PASS') passed += 1; - if (item.status === 'FAIL') errors.fail.push(`${item.id}: FAIL ${item.reason || ''}`.trim()); - if (item.status === 'BLOCKED' || item.status === 'SKIP') errors.blocked.push(`${item.id}: ${item.status} is not releasable (${item.reason || 'no reason'})`); - if (!Array.isArray(item.evidence)) errors.fail.push(`${item.id}: evidence must be an array`); - for (const evidence of (Array.isArray(item.evidence) ? item.evidence : [])) { - const resolved = typeof evidence === 'string' ? path.resolve(path.dirname(options.report), evidence) : ''; - if (!resolved || !resolved.startsWith(path.resolve(path.dirname(options.report)) + path.sep)) errors.fail.push(`${item.id}: evidence path escapes the run directory`); - else if (!fs.existsSync(resolved)) errors.fail.push(`${item.id}: missing evidence ${evidence}`); - } - } - if (requiredIds) { - for (const id of requiredIds) if (!seen.has(id)) errors.fail.push(`${id}: required case missing from report`); - for (const id of seen.keys()) if (!requiredIds.includes(id)) errors.fail.push(`${id}: case is not in the required list`); - if (JSON.stringify(report.required_cases) !== JSON.stringify(requiredIds)) errors.fail.push('report required_cases differs from the runner list'); - } - const coverage = report.coverage; - if (!coverage || coverage.required !== cases.length || coverage.passed !== passed - || (requiredIds && coverage.required !== requiredIds.length)) errors.fail.push('coverage does not match cases'); - if (!Array.isArray(report.failures) || !Array.isArray(report.blocked)) errors.fail.push('failures and blocked must be arrays'); - else if (report.failures.length > 0) errors.fail.push('client report contains failures'); - if (report.status === 'PASS' && (passed !== cases.length || report.blocked?.length)) errors.fail.push('client PASS contradicts its cases'); - - // Staleness: the client report must have been written during this run. - try { - const mtime = fs.statSync(options.report).mtimeMs; - if (mtime + MTIME_SLACK_MS < Number(options.started_ms)) errors.fail.push('client report predates this run (stale)'); - } catch { /* unreadable already reported */ } - - // Event contract. - let lines = []; - if (fs.existsSync(options.events)) { - const text = fs.readFileSync(options.events, 'utf8'); - scanSecrets('events', text, errors); - lines = text.split('\n').filter((line) => line.trim() !== ''); - } - if (report.status === 'PASS' && lines.length === 0) errors.fail.push('PASS report has no events'); - if (Number(report.event_count ?? 0) !== lines.length) errors.fail.push(`event_count ${report.event_count} does not match events lines ${lines.length}`); - let badEvents = 0; - let lastUs = -Infinity; - for (const line of lines) { - let event; - try { event = JSON.parse(line); } catch { badEvents += 1; continue; } - if (!isObject(event) || !EVENT_KEYS.every((key) => Object.hasOwn(event, key)) || event.run_id !== options.run_id - || !Number.isSafeInteger(event.monotonic_us) || event.monotonic_us < 0 - || typeof event.payload !== 'object' || event.payload === null || Array.isArray(event.payload) - || !Number.isInteger(event.connection_epoch) || event.monotonic_us < lastUs) badEvents += 1; - else lastUs = event.monotonic_us; - } - if (badEvents > 0) errors.fail.push(`${badEvents} events violate the contract (keys/run_id/order)`); -} - -function validateProcess(options, errors) { - const code = Number(options.process_code); - if (options.timed_out === '1') errors.fail.push(`wall-clock timeout (raw exit ${code})`); - if (code !== 0) errors.fail.push(`child process exit code ${code}`); - if (options.redactor_code !== undefined && Number(options.redactor_code) !== 0) errors.fail.push('log redactor did not finish cleanly'); - if (!fs.existsSync(options.log)) { - errors.fail.push('client log does not exist'); - return; - } - const text = fs.readFileSync(options.log, 'utf8'); - scanSecrets('client log', text, errors); - const hits = text.split(/\r?\n/).map((line, index) => [line, index]).filter(([line]) => ERROR_RE.test(line)); - // Report the line numbers and the matched rule, not arbitrary log text. - for (const [line, index] of hits.slice(0, 50)) errors.fail.push(`log:${index + 1}: ${line.match(ERROR_RE)[0]}`); - if (hits.length > 50) errors.fail.push(`log: ${hits.length - 50} more error lines`); -} - -function sealRun(options) { - const errors = new Findings(); - const input = readJson(options.report); - validateReport(input, options, errors); - validateProcess(options, errors); - const report = input.__read_error ? { - schema_version: 1, run_id: options.run_id, suite: options.suite, status: 'FAIL', - build: readJson(options.build), cases: [], failures: [input.__read_error], blocked: [], - coverage: { required: 0, passed: 0 }, - } : input; - let status = 'PASS'; - if (errors.fail.length > 0) status = 'FAIL'; - else if (errors.blocked.length > 0) status = 'BLOCKED'; - if (options.require_pass && status === 'PASS' && report.status !== 'PASS') { status = 'FAIL'; errors.fail.push(`client status ${report.status}`); } - report.status = status; - report.exit_gate = { - checked: true, - process_code: Number(options.process_code), - timed_out: options.timed_out === '1', - errors: errors.all, - }; - const text = `${JSON.stringify(report, null, 2)}\n`; - const leak = new Findings(); - scanSecrets('final report', text, leak); - const output = options.output; - fs.mkdirSync(path.dirname(path.resolve(output)), { recursive: true }); - if (leak.fail.length > 0) { - // Never persist a report that would carry the secret; keep only the verdict. - fs.writeFileSync(output, `${JSON.stringify({ schema_version: 1, run_id: options.run_id, suite: options.suite, status: 'FAIL', - exit_gate: { checked: true, process_code: Number(options.process_code), timed_out: options.timed_out === '1', errors: leak.fail } }, null, 2)}\n`); - console.error('PLAYABLE REPORT: FAIL (credential literal in report)'); - return 1; - } - fs.writeFileSync(output, text); - if (status !== 'PASS') { - console.error(`PLAYABLE REPORT: ${status} (${errors.all.length} findings)`); - errors.all.slice(0, 40).forEach((error) => console.error(`- ${error}`)); - return status === 'BLOCKED' ? 2 : 1; - } - console.log(`PLAYABLE REPORT: PASS ${output}`); - return 0; -} - -function sealRelease(dir) { - const errors = new Findings(); - const root = path.resolve(dir); - const manifest = readJson(path.join(root, 'release-manifest.json')); - const summary = { schema_version: 1, status: 'FAIL', candidate: null, runs: [], counts: {}, errors: [] }; - if (manifest.__read_error) errors.fail.push(`release-manifest.json unreadable: ${manifest.__read_error}`); - else { - if (manifest.schema_version !== 1) errors.fail.push('manifest schema_version must be 1'); - checkBuild(manifest.candidate, null, 'manifest.', errors); - summary.candidate = manifest.candidate; - const runs = Array.isArray(manifest.runs) ? manifest.runs : []; - if (runs.length === 0) errors.fail.push('manifest lists no runs'); - const ids = new Set(); - const passedBySuite = {}; - const blockedBySuite = {}; - for (const run of runs) { - const label = `run ${run?.run_id ?? '?'}`; - if (typeof run?.run_id !== 'string' || typeof run?.path !== 'string' || !SUITES.has(run?.suite)) { - errors.fail.push(`${label}: run_id/path/suite missing`); - continue; - } - if (ids.has(run.run_id)) errors.fail.push(`${label}: listed twice`); - ids.add(run.run_id); - const reportPath = path.resolve(root, run.path, 'report.json'); - if (!reportPath.startsWith(root + path.sep)) { - errors.fail.push(`${label}: path escapes the release directory`); - continue; - } - const report = readJson(reportPath); - const entry = { run_id: run.run_id, suite: run.suite, path: run.path, status: report.status ?? 'MISSING' }; - summary.runs.push(entry); - if (report.__read_error) { - // run_client_gate.sh records precondition stops in gate.log before any client starts. - const gateLog = path.resolve(root, run.path, 'gate.log'); - const preconditionBlocked = fs.existsSync(gateLog) && /^BLOCKED /m.test(fs.readFileSync(gateLog, 'utf8')); - if (preconditionBlocked) { - entry.status = 'BLOCKED'; - blockedBySuite[run.suite] = (blockedBySuite[run.suite] || 0) + 1; - errors.blocked.push(`${label}: precondition BLOCKED before launch`); - } else errors.fail.push(`${label}: sealed report missing`); - continue; - } - if (report.run_id !== run.run_id) errors.fail.push(`${label}: report run_id mismatch`); - if (report.suite !== run.suite) errors.fail.push(`${label}: report suite mismatch`); - if (report.exit_gate?.checked !== true || report.exit_gate?.process_code !== 0 || report.exit_gate?.timed_out !== false) errors.fail.push(`${label}: report not sealed by a clean exit gate`); - // The client exit gate runs before the external RSS seal. An interrupted - // or crashed memory sealer must not leave a releasable intermediate PASS. - if (run.suite === 'soak' && report.status === 'PASS') { - const memoryCases = Array.isArray(report.runner_cases) - ? report.runner_cases.filter((item) => item?.id === 'STB-MEMORY-01') : []; - if (memoryCases.length !== 1 || memoryCases[0].status !== 'PASS' - || report.memory?.verdict?.status !== 'PASS') { - errors.fail.push(`${label}: STB-MEMORY-01 external memory seal missing or not PASS`); - } - } - checkBuild(report.build, manifest.candidate, `${label}: `, errors); - if (report.status === 'PASS') passedBySuite[run.suite] = (passedBySuite[run.suite] || 0) + 1; - else if (report.status === 'BLOCKED') { - blockedBySuite[run.suite] = (blockedBySuite[run.suite] || 0) + 1; - errors.blocked.push(`${label}: BLOCKED`); - } - else errors.fail.push(`${label}: ${report.status}`); - } - const requiredRuns = manifest.required_runs && typeof manifest.required_runs === 'object' ? manifest.required_runs : {}; - if (Object.keys(requiredRuns).length === 0) errors.fail.push('manifest required_runs is empty'); - for (const [suite, count] of Object.entries(requiredRuns)) { - const got = passedBySuite[suite] || 0; - summary.counts[suite] = { required: count, passed: got }; - if (!Number.isInteger(count) || count < 1) errors.fail.push(`required_runs.${suite} must be a positive integer`); - else if (got < count) { - // Missing passes explained by BLOCKED runs stay BLOCKED; anything else is a FAIL. - const message = `${suite}: ${got}/${count} passing runs`; - if (got + (blockedBySuite[suite] || 0) >= count) errors.blocked.push(message); - else errors.fail.push(message); - } - } - } - summary.status = errors.fail.length ? 'FAIL' : (errors.blocked.length ? 'BLOCKED' : 'PASS'); - summary.errors = errors.all; - fs.writeFileSync(path.join(root, 'release-report.json'), `${JSON.stringify(summary, null, 2)}\n`); - if (summary.status !== 'PASS') { - console.error(`PLAYABLE RELEASE: ${summary.status}`); - summary.errors.forEach((error) => console.error(`- ${error}`)); - return summary.status === 'BLOCKED' ? 2 : 1; - } - console.log(`PLAYABLE RELEASE: PASS ${path.join(root, 'release-report.json')}`); - return 0; -} - -function main() { - let options; - try { options = argsOf(process.argv.slice(2)); } - catch (error) { usage(); console.error(error.message); return 2; } - if (options.help) { usage(); return 0; } - if (options.release_dir) return sealRelease(options.release_dir); - return sealRun(options); -} - -process.exitCode = main(); diff --git a/test/README.md b/test/README.md index 289cc490..f0dc3306 100644 --- a/test/README.md +++ b/test/README.md @@ -1,22 +1,12 @@ -# test/ +# Test data -| 文件 / 目录 | 产出者 | 内容 | -|---|---|---| -| `golden/` | M1 起 | Godot 渲染的对拍截图(`m1-*.png` / `m2-*.png` / `m25-*.png`) | -| `bgfx-reference/` | 归档自 xrender-poc | bgfx demo 的同题材参考截图 + `render-samples/`。Godot 输出与之**结构化目视 + 直方图**对比(渲染器不同,不做像素比) | -| `assets.list` | xrender-poc M0 T9c | 精选 `.gr2`(多部件 / 全 4 级 LOD / 多 stage 材质 / 异常个例);里程碑对拍跑这套 | -| `assets-good.txt` | xrender-poc | 全量可解析清单 | -| `noise_floor.json` | xrender-poc M2 | 对拍噪声底:同 DLL 确定性 0、跨实现 ≤6.5e-5、epsilon=1e-3。Godot 对拍沿用此口径 | -| `m2-numeric.json` | `oracle/run-diff-suite.sh` | libgr2 vs Granny oracle 逐字段对拍结果(23 用例)。**libgr2 vendored 进本仓库后,这条链仍用 xrender-poc 时期建立的基线** | -| `godot-macos-stress.json` | M2 T2.7 | 1–100 角色 `_process` 帧时间 + 资产构建成本采样(供 Phase 2 A/B 决策,非门禁) | -| `compare.py` | mtgodot 回归 harness | 运行 CPU/GPU 固定时间截图;Godot 退出/超时/缺截图/0 surface/缺 selfcheck/NaN 为硬失败,CPU↔GPU 像素差暂作 advisory | -| `oracle_dumps/` | `run-diff-suite.sh`(gitignored) | oracle 二进制转储中间产物 | +`golden/` and `bgfx-reference/` contain historical reference screenshots. `assets.list` and `assets-good.txt` list asset samples; `m2-numeric.json` records Granny oracle comparisons. -## 跑 libgr2 回归 +Build the asset tools without a UI engine: -```bash -cmake -B build-tools -DMTGODOT_BUILD_EXTENSION=OFF -DMTGODOT_BUILD_TOOLS=ON && cmake --build build-tools -j8 -./build-tools/tools/gr2fuzz "$PWD/../m2dev-client-main/assets" # 全量解析:期望 9166/9166 ok -./oracle/run-diff-suite.sh # vs Granny:期望 23/23 PASS(需 Wine + oracle.exe) -ctest --test-dir build --output-on-failure # loader 损坏输入单测 +```sh +cmake -S . -B build-tools -DMT_BUILD_TOOLS=ON +cmake --build build-tools -j8 ``` + +The active 40250 port tests run through `script/port_gate.sh macos`. The native SDL/Vulkan client has its own acceptance runner, `script/native_mac_acceptance.mjs`. diff --git a/test/compare.py b/test/compare.py deleted file mode 100755 index 384809c3..00000000 --- a/test/compare.py +++ /dev/null @@ -1,206 +0,0 @@ -#!/usr/bin/env python3 -"""Phase-1 cross-check harness. - -Skinning's numeric truth lives in libgr2 (shared by mtgodot and the retired -xrender-poc) and is oracle-verified against real Granny. mtgodot's default -CPU-skin path applies libgr2's `skin` matrices verbatim, so "mtgodot == Granny" -reduces to "libgr2 == Granny" (oracle suite) + "mtgodot ran libgr2 cleanly" -(0 NaN). It renders each case on the CPU path and, for animated cases, also on the -GPU-skin (SRC_SKIN vertex shader) path. No pixel diff vs bgfx-reference -(renderers/cameras differ) — the reference shot path is recorded for eyeballing. - - test/compare.py [--cases test/compare-cases.json] [--out test/compare-report.json] - -Prereqs used if present: - - test/m2-numeric.json (from oracle/run-diff-suite.sh; libgr2 vs Granny) - - godot on PATH, extension built (project/bin/*.dylib) - - Pillow + numpy -""" -import json, os, re, subprocess, sys, time -from pathlib import Path -import numpy as np -from PIL import Image - -REPO = Path(__file__).resolve().parent.parent -# Metin2 assets live in-tree at /assets (gitignored); override with MT_ASSETS. -ASSETS = Path(os.environ.get("MT_ASSETS", REPO / "assets")).resolve() -OUT_DIR = REPO / "test" / "compare-shots" -REF_DIR = REPO / "test" / "bgfx-reference" - -DEFAULT_CASES = [ - # name, model (rel to assets/PC/ymir work), texdir, anim ('-' = bind), t - ("warrior-bind", "pc/warrior/warrior_cheongrin.gr2", "pc/warrior", "-", 0.0), - ("warrior-dance-t7", "pc/warrior/warrior_cheongrin.gr2", "pc/warrior", - "pc/warrior/action/dance_1.gr2", 7.0), - ("warrior-dance-t19","pc/warrior/warrior_cheongrin.gr2", "pc/warrior", - "pc/warrior/action/dance_1.gr2", 19.0), - ("sura-bind", "pc/sura/sura_lord.gr2", "pc/sura", "-", 0.0), - ("assassin-bind", "pc/assassin/assassin.gr2", "pc/assassin", "-", 0.0), - ("shaman-bind", "pc/shaman/shaman_lord.gr2", "pc/shaman", "-", 0.0), -] - - -def run_godot(env_extra, timeout=120): - godot = subprocess.run(["which", "godot"], capture_output=True, text=True).stdout.strip() - if not godot: - return None, "godot not on PATH", None - env = {**os.environ, **env_extra} - try: - p = subprocess.run([godot, "--path", str(REPO / "project"), - "res://main.tscn", "--rendering-driver", "metal", - "--quit-after", "45"], - capture_output=True, text=True, env=env, timeout=timeout) - except subprocess.TimeoutExpired: - return None, "timeout", None - log = p.stdout + p.stderr - if p.returncode != 0: - return log, f"godot exited with status {p.returncode}", p.returncode - return log, None, p.returncode - - -def case_env(c): - _, model, texdir, anim, t = c - e = {"MTGODOT_MODEL": str(ASSETS / "PC" / "ymir work" / model), - "MTGODOT_TEXDIR": str(ASSETS / "PC" / "ymir work" / texdir), - "MTGODOT_YAW": "0.7"} - if anim == "-": - e["MTGODOT_ANIM"] = "-" - else: - e["MTGODOT_ANIM"] = str(ASSETS / "PC" / "ymir work" / anim) - e["MTGODOT_ANIM_T"] = str(t) - return e - - -def _luma(path): - im = np.asarray(Image.open(path).convert("RGB")).astype(np.float32) - return 0.2126 * im[..., 0] + 0.7152 * im[..., 1] + 0.0722 * im[..., 2], im - - -def img_stats(path): - l, _ = _luma(path) - return {"luma_mean": round(float(l.mean()), 2), "luma_std": round(float(l.std()), 2)} - - -def img_diff(a, b): - """Coarse diff between two same-camera renders (CPU vs GPU skinning). - - Advisory only — both paths feed the SAME libgr2 matrices with the full affine, - so they match visually; residual changed_frac is AA / float-precision on the - shaded surface, not a collapse. (The old Skeleton3D route DID collapse limbs; - it's gone — replaced by the SRC_SKIN vertex shader.) Eyeball the .cpu/.gpu PNGs. - """ - la, ia = _luma(a) - lb, ib = _luma(b) - if ia.shape != ib.shape: - return {"error": "size mismatch"} - d = np.abs(ia - ib).sum(axis=2) - return {"changed_frac": round(float((d > 24).mean()), 4), - "max_abs": int(d.max()), "mean_abs": round(float(d.mean()), 3)} - - -def parse(log, pat): - m = re.search(pat, log or "") - return m.group(1) if m else None - - -def report_path(path): - try: - return str(path.relative_to(REPO)) - except ValueError: - return str(path) - - -def oracle_gate(): - js = REPO / "test" / "m2-numeric.json" - if not js.exists(): - return {"status": "skipped", "reason": "test/m2-numeric.json absent — run oracle/run-diff-suite.sh"} - d = json.loads(js.read_text()) - cases = d.get("cases", []) - fails = [c for c in cases if str(c.get("status", "")).upper() != "PASS"] - return {"status": "pass" if cases and not fails else ("fail" if fails else "empty"), - "cases": len(cases), "failures": len(fails), - "worst_mat": max((float(c.get("mat", 0)) for c in cases), default=0.0), - "worst_vtx": max((float(c.get("vtx", 0)) for c in cases), default=0.0), - "note": "libgr2 vs real Granny 2.9.12 — shared by mtgodot's CPU-skin path"} - - -def main(): - cases_path = out_path = None - args = sys.argv[1:] - for i, a in enumerate(args): - if a == "--cases" and i + 1 < len(args): cases_path = Path(args[i + 1]) - if a == "--out" and i + 1 < len(args): out_path = Path(args[i + 1]) - out_path = out_path or REPO / "test" / "compare-report.json" - cases = json.loads(cases_path.read_text()) if cases_path and cases_path.exists() else DEFAULT_CASES - OUT_DIR.mkdir(parents=True, exist_ok=True) - - report = {"generated": time.strftime("%Y-%m-%d %H:%M:%S"), - "oracle_gate": oracle_gate(), "cases": []} - fails = [] - - for c in cases: - name, anim = c[0], c[3] - rec = {"name": name} - env = case_env(c) - - shot = OUT_DIR / f"{name}.cpu.png" - shot.unlink(missing_ok=True) # stale output must never turn a failed run green - log, err, returncode = run_godot({**env, "MTGODOT_AUTOSHOT": str(shot)}) - nan = parse(log, r"NaN/Inf=(\d+)") - surf = parse(log, r"surfaces=(\d+)") - cpu = {"nan_inf": int(nan) if nan is not None else None, - "surfaces": int(surf) if surf else None, - "shot": report_path(shot) if shot.exists() else None, - "returncode": returncode, "error": err} - if shot.exists(): - cpu.update(img_stats(shot)) - rec["cpu"] = cpu - if err: - fails.append(f"{name}: {err}") - if not shot.exists(): - fails.append(f"{name}: screenshot missing") - if cpu["surfaces"] is None or cpu["surfaces"] <= 0: - fails.append(f"{name}: model did not report a non-empty surface set") - if anim != "-" and cpu["nan_inf"] is None: - fails.append(f"{name}: animation self-check result missing") - elif cpu["nan_inf"] not in (0, None): - fails.append(f"{name}: NaN/Inf={cpu['nan_inf']}") - - if anim != "-": - gshot = OUT_DIR / f"{name}.gpu.png" - gshot.unlink(missing_ok=True) - glog, gerr, greturncode = run_godot( - {**env, "MTGODOT_GPUSKIN": "1", "MTGODOT_AUTOSHOT": str(gshot)}) - gpu = {"shot": report_path(gshot) if gshot.exists() else None, - "returncode": greturncode, "error": gerr} - if gshot.exists(): - gpu.update(img_stats(gshot)) - if shot.exists(): - gpu["vs_cpu"] = img_diff(shot, gshot) - rec["gpu_skin"] = gpu - if gerr: - fails.append(f"{name} GPU: {gerr}") - if not gshot.exists(): - fails.append(f"{name} GPU: screenshot missing") - - ref = next((p for p in REF_DIR.glob(f"*{name.split('-')[0]}*")), None) - rec["bgfx_reference"] = str(ref.relative_to(REPO)) if ref else None - report["cases"].append(rec) - g = rec.get("gpu_skin", {}) - print(f" {name:18s} cpu[nan={cpu['nan_inf']} surf={cpu['surfaces']} luma={cpu.get('luma_mean')}]" - + (f" gpu[changed_vs_cpu={g.get('vs_cpu', {}).get('changed_frac')}]" if g else "")) - - out_path.parent.mkdir(parents=True, exist_ok=True) - out_path.write_text(json.dumps(report, indent=2)) - og = report["oracle_gate"] - print(f"\noracle gate: {og['status']}" - + (f" ({og['cases']} cases, worst mat={og['worst_mat']:.1e} vtx={og['worst_vtx']:.1e})" - if og.get("cases") else f" — {og.get('reason', '')}")) - print(f"cpu failures: {fails or 'none'}") - print("gpu-skin (SRC_SKIN vertex shader) rendered for each anim case — eyeball .gpu.png vs .cpu.png") - print(f"report -> {report_path(out_path)}") - return 1 if (og["status"] == "fail" or fails) else 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/test/godot-macos-stress.json b/test/godot-macos-stress.json deleted file mode 100644 index 9ebe63ff..00000000 --- a/test/godot-macos-stress.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "measured": "2026-08-29 (foreground window, M2, Metal, vsync off)", - "method": "per-frame CPU LBS + full ArrayMesh rebuild (current default)", - "caveats": [ - "frame_ms is the trustworthy signal; cpu_process_ms (Performance.TIME_PROCESS) reads inconsistent (drops as N rises) \u2014 do not use.", - "per-frame cpu_mesh->clear_surfaces()+add_surface_from_arrays is the likely bottleneck, not the LBS math.", - "build ~170ms/char is single-threaded, no cross-model texture/mesh cache.", - "Phase 2 needs: (1) custom GPU skinning shader (full bone matrices) so this path is not on the CPU, (2) multithreaded build + eterpack shared assets, (3) a proper bench harness." - ], - "runs": [ - { - "characters": 1, - "build_ms_per_char": 171.7, - "frame_ms_avg": 21.8, - "fps_avg": 46.0 - }, - { - "characters": 10, - "build_ms_per_char": 171.1, - "frame_ms_avg": 27.5, - "fps_avg": 36.3 - }, - { - "characters": 30, - "build_ms_per_char": 168.7, - "frame_ms_avg": 41.2, - "fps_avg": 24.3 - } - ] -} \ No newline at end of file diff --git a/test/playable/build.fixture.json b/test/playable/build.fixture.json deleted file mode 100644 index 2f3c07b1..00000000 --- a/test/playable/build.fixture.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "engine_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "extension_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - "pck_sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", - "arch": "arm64" -} diff --git a/test/playable/clean.log b/test/playable/clean.log deleted file mode 100644 index 92187ecb..00000000 --- a/test/playable/clean.log +++ /dev/null @@ -1 +0,0 @@ -PLAYABLE FIXTURE: child completed without renderer errors diff --git a/test/playable/events.valid.jsonl b/test/playable/events.valid.jsonl deleted file mode 100644 index c6f3fe2a..00000000 --- a/test/playable/events.valid.jsonl +++ /dev/null @@ -1 +0,0 @@ -{"monotonic_us":1000,"run_id":"fixture-run","case_id":"FIXTURE-01","connection_epoch":1,"stage":"LOGIN","kind":"phase_changed","actor_vid":0,"target_vid":0,"payload":{"source":"server"}} diff --git a/test/playable/fake_client.sh b/test/playable/fake_client.sh deleted file mode 100755 index d84b8d02..00000000 --- a/test/playable/fake_client.sh +++ /dev/null @@ -1,55 +0,0 @@ -#!/bin/sh -# Stand-in for the packaged client used by script/playable_gate_test.sh. -# The fake app's Mach-O execs this file, so it runs as the runner's own child -# PID. It never contacts a server; MT_FAKE_SCENARIO picks the behaviour. -scenario="${MT_FAKE_SCENARIO:-pass}" - -write_report() { - # $1 = PASS|BLOCKED, $2 = drop-last|evidence-missing|wrong-run|"" - node -e ' -const fs = require("node:fs"); -const path = require("node:path"); -const [status, variant] = process.argv.slice(1); -const out = process.env.MT_TEST_OUTPUT; -const required = JSON.parse(fs.readFileSync(path.join(out, "required-cases.json"), "utf8")).cases; -const runId = variant === "wrong-run" ? "previous-run-0000" : process.env.MT_PLAYABLE_RUN_ID; -const ids = variant === "drop-last" ? required.slice(0, -1) : required; -const cases = ids.map((id) => ({ id, status, duration_ms: 1, reason: status === "PASS" ? "fake" : "fixture_not_ready", - evidence: variant === "evidence-missing" && id === "NET-LOGIN-01" ? ["net-login-01.png"] : [] })); -const events = ids.map((id, i) => JSON.stringify({ monotonic_us: 1000 + i, run_id: runId, case_id: id, connection_epoch: 1, - stage: "LOGIN", kind: "phase_changed", actor_vid: 0, target_vid: 0, payload: { source: "server" } })); -fs.writeFileSync(process.env.MT_TEST_EVENTS, events.join("\n") + "\n"); -const report = { - schema_version: 1, run_id: runId, suite: process.env.MT_PLAYABLE_SUITE || process.env.MT_TEST_MODE, status, - build: { engine_sha256: process.env.MT_BUILD_ENGINE_SHA256, extension_sha256: process.env.MT_BUILD_EXTENSION_SHA256, - pck_sha256: process.env.MT_BUILD_PCK_SHA256, arch: process.env.MT_BUILD_ARCH }, - environment: { os: "macOS", renderer: "fake", resolution: [1280, 720] }, - cases, required_cases: ids, events: "events.jsonl", event_count: events.length, failures: [], - blocked: status === "BLOCKED" ? ids.map((id) => `${id}: fixture_not_ready`) : [], - coverage: { required: cases.length, passed: status === "PASS" ? cases.length : 0 }, - exit_gate: { checked: false, process_code: null, errors: [] }, -}; -fs.writeFileSync(process.env.MT_TEST_REPORT, JSON.stringify(report, null, 2)); -' "$1" "${2:-}" -} - -case "$scenario" in - cancel) exec node -e 'require("node:fs").writeFileSync(process.env.MT_TEST_OUTPUT + "/fake-child.pid", String(process.pid)); setInterval(() => {}, 1000)' ;; - pass) write_report PASS; echo "PLAYABLE CLIENT: PASS" ;; - soak-pass) write_report PASS; if [ "${MT_PLAYABLE_SUITE:-}" = "soak" ]; then sleep 3; fi; echo "PLAYABLE CLIENT: PASS" ;; - exit3) write_report PASS; echo "PLAYABLE CLIENT: PASS"; exit 3 ;; - signal) write_report PASS; kill -KILL $$ ;; - hang) trap '' TERM; echo "ignoring TERM"; while :; do sleep 1; done ;; - stale) write_report PASS wrong-run ;; - no-report) echo "exited before writing a report" ;; - rid) write_report PASS; echo "WARNING: 3 shaders of type CanvasItem were never freed." ;; - leak) write_report PASS; echo "WARNING: ObjectDB instances leaked at exit (run with --verbose for details)." ;; - missing-case) write_report PASS drop-last ;; - missing-evidence) write_report PASS evidence-missing ;; - blocked) write_report BLOCKED ;; - print-secret) write_report PASS; echo "login request for $MT_ACCOUNT with $MT_PASSWORD" ;; - fifo-holder) write_report PASS; sleep 13 & exit 0 ;; - no-creds) if [ -n "${MT_ACCOUNT:-}${MT_PASSWORD:-}" ]; then echo "SCRIPT ERROR: credentials reached an offline child"; fi; write_report PASS ;; - *) echo "unknown fake scenario" >&2; exit 64 ;; -esac -exit 0 diff --git a/test/playable/forest-viewpoints.example.json b/test/playable/forest-viewpoints.example.json deleted file mode 100644 index 4f0a3f3b..00000000 --- a/test/playable/forest-viewpoints.example.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "schema_version": 1, - "maps": { - "": { - "races": [], - "viewpoints": [ - { - "id": "flat-01", - "kind": "flat", - "status": "unconfirmed", - "server_cm": [0, 0], - "camera_yaw_deg": 45 - } - ] - } - }, - "notes": "Copy to test/playable/forest-viewpoints..local.json (ignored). kind is flat|slope|dense|warp; server_cm is the confirmed in-game global position; status becomes confirmed only with confirmed_by set to the confirming role (not a person's name). races lists server-confirmed mob vnums for the map; empty means the 12 candidate tree monsters are placed as candidates only. Never add credentials here." -} diff --git a/test/playable/report.blocked.json b/test/playable/report.blocked.json deleted file mode 100644 index 4af14d32..00000000 --- a/test/playable/report.blocked.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "schema_version": 1, - "run_id": "fixture-run", - "suite": "playable", - "status": "BLOCKED", - "build": { - "engine_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "extension_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - "pck_sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", - "arch": "arm64" - }, - "environment": { - "os": "macOS", - "renderer": "Metal", - "resolution": [ - 1280, - 720 - ] - }, - "cases": [ - { - "id": "FIXTURE-01", - "status": "BLOCKED", - "duration_ms": 0, - "reason": "fixture_not_ready", - "evidence": [] - } - ], - "required_cases": [ - "FIXTURE-01" - ], - "events": "events.jsonl", - "event_count": 1, - "failures": [], - "blocked": [ - "FIXTURE-01: fixture_not_ready" - ], - "coverage": { - "required": 1, - "passed": 0 - }, - "exit_gate": { - "checked": false, - "process_code": null, - "errors": [] - } -} diff --git a/test/playable/report.valid.json b/test/playable/report.valid.json deleted file mode 100644 index 43763abd..00000000 --- a/test/playable/report.valid.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "schema_version": 1, - "run_id": "fixture-run", - "suite": "playable", - "status": "PASS", - "build": { - "engine_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "extension_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - "pck_sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", - "arch": "arm64" - }, - "environment": { - "os": "macOS", - "renderer": "Metal", - "resolution": [ - 1280, - 720 - ] - }, - "cases": [ - { - "id": "FIXTURE-01", - "status": "PASS", - "duration_ms": 1, - "reason": "ok", - "evidence": [] - } - ], - "required_cases": [ - "FIXTURE-01" - ], - "events": "events.jsonl", - "event_count": 1, - "failures": [], - "blocked": [], - "coverage": { - "required": 1, - "passed": 1 - }, - "exit_gate": { - "checked": false, - "process_code": null, - "errors": [] - } -} diff --git a/test/playable/required-cases.fixture.json b/test/playable/required-cases.fixture.json deleted file mode 100644 index 31b5fdaa..00000000 --- a/test/playable/required-cases.fixture.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "schema_version": 1, - "suite": "playable", - "cases": [ - "FIXTURE-01" - ] -} diff --git a/test/playable/rid-warning.log b/test/playable/rid-warning.log deleted file mode 100644 index b4af7484..00000000 --- a/test/playable/rid-warning.log +++ /dev/null @@ -1 +0,0 @@ -WARNING: 3 shaders of type CanvasItem were never freed. diff --git a/test/playable/scenario.example.json b/test/playable/scenario.example.json deleted file mode 100644 index 3e7076fe..00000000 --- a/test/playable/scenario.example.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "schema_version": 1, - "scenario_id": "first-mac-playable-forest", - "protocol": "classic", - "server": { - "server_index": 0, - "channel": 1, - "auth_host": "", - "auth_port": 0, - "game_host": "", - "game_port": 0 - }, - "character_slot": 0, - "map_key": "", - "waypoints_cm": [], - "allowed_mob_vnums": [], - "allowed_drop_vnums": [], - "skill_cases": [], - "resolution": [1280, 720], - "loops": 3, - "timeout_seconds": 900, - "soak": { - "duration_seconds": 7200, - "rest_seconds": 30, - "warmup_rounds": 1, - "min_rounds": 10, - "reconnects": 10, - "exits": 10, - "resolutions": { - "sizes": [[1280, 720], [1440, 900], [1600, 1000]], - "switches_per_size": 10 - }, - "warp": { - "status": "unconfirmed", - "notes": "Set status=confirmed only after the environment owner verifies a legal portal: required (even, >=20 out-and-back legs), portal_cm, destination_map_key, return_portal_cm, cross_server." - }, - "faults": { - "status": "unconfirmed", - "notes": "Set status=confirmed only with a confirmed 127.0.0.1 test proxy (mode local_proxy, per_type, unreachable_seconds 1..20, upstream {auth_host,auth_port,game_host,game_port} = the real test server). server.* and the serverlist entry must then be 127.0.0.1 proxy ports; script/run_client_gate.sh starts script/playable_fault_proxy.mjs for this client only. A server warp that hands out a direct game address bypasses the proxy. Never change global routes or firewall; active reconnect is not a substitute." - } - }, - "notes": "Copy to test/playable/.local.json (ignored) and fill from the confirmed server fixture: server.* must equal the serverinfo entry for server_index/channel; map_key is the asset directory holding Setting.txt; waypoints are server cm. Never add credentials here. The soak suite (STB-01) additionally needs timeout_seconds >= soak.duration_seconds + 900." -} diff --git a/test/playable/skill-cases.json b/test/playable/skill-cases.json deleted file mode 100644 index 781fe58a..00000000 --- a/test/playable/skill-cases.json +++ /dev/null @@ -1,68 +0,0 @@ -{ - "schema_version": 1, - "notes": [ - "每职业从测试角色真实已学技能中选择:single_target 单体、area 范围、self_buff 自身增益、flying 飞行效果。", - "status=unconfirmed:尚未由测试环境负责人确认,对应 CBT 用例为 BLOCKED,不得发布。", - "status=confirmed:填 skill_id(该职业已学、非被动)、target(enemy|self|ground)、repeats(>=10)、required_evidence(至少一个服务端证据)。", - "status=not_applicable:该职业确无此类技能,必须填 checklist_ref 指向发布清单中的确认记录。", - "脚本和测试从不补技能 ID;只改本地副本 test/playable/skill-cases.local.json 时同样适用。检查:godot --headless --path project --script playable_skill_matrix.gd -- --job WARRIOR" - ], - "jobs": { - "WARRIOR": { - "single_target": { - "status": "unconfirmed" - }, - "area": { - "status": "unconfirmed" - }, - "self_buff": { - "status": "unconfirmed" - }, - "flying": { - "status": "unconfirmed" - } - }, - "ASSASSIN": { - "single_target": { - "status": "unconfirmed" - }, - "area": { - "status": "unconfirmed" - }, - "self_buff": { - "status": "unconfirmed" - }, - "flying": { - "status": "unconfirmed" - } - }, - "SURA": { - "single_target": { - "status": "unconfirmed" - }, - "area": { - "status": "unconfirmed" - }, - "self_buff": { - "status": "unconfirmed" - }, - "flying": { - "status": "unconfirmed" - } - }, - "SHAMAN": { - "single_target": { - "status": "unconfirmed" - }, - "area": { - "status": "unconfirmed" - }, - "self_buff": { - "status": "unconfirmed" - }, - "flying": { - "status": "unconfirmed" - } - } - } -} diff --git a/test/rendering/README.md b/test/rendering/README.md deleted file mode 100644 index 12f9340f..00000000 --- a/test/rendering/README.md +++ /dev/null @@ -1,73 +0,0 @@ -# 渲染回归入口 - -第一批用例针对 R1 场景装配与 R2 角色选择,2026-09-08 开始执行。未宣称 40250 像素一致或最终包通过。 - -在仓库根目录运行: - -本地 macOS 修改 C++ 后先执行 `bash script/rendering_test.sh`:它构建并同步 Godot 实际加载的 debug 路径,重签名后运行 CPU 绕序回归与选角截图。仅构建 Release 不会更新本地 Godot 的 debug 扩展。可传 `--hair=1001`;截图与哈希报告在 `build/rendering/selection--/`。`--job=0..7` 与 `--main-part=0` 可复现无装备的八种族选择矩阵;`MTGODOT_GPUSKIN=1` 验证 GPU 路径。`--no-hair` 和 `--solid-body` 仅用于隔离诊断,其画面不能当作普通角色验收。 - -```bash -godot --headless --path project --script main_character_sync_test.gd -godot --headless --path project --script gamescene_test.gd -godot --path project --script gamescene_visual_test.gd -godot --path project --script gamescene_visual_test.gd -- --c1 -``` - -- `main_character_sync_test.gd` 使用可控模型工厂,覆盖晚到实体/缺 race、网络重入、重复事件、移动不被回滚、失败节流与重试上限、VID 变化导致旧构建失效。通过标志是 `failures=0`,不能只看 Godot 退出码。 -- `gamescene_test.gd` 使用不存在的地图,预期有加载错误日志;它只验证无地图装配,不能当作画面正常证据。 -- `gamescene_visual_test.gd` 使用真实资产和实际 GameScene,通过 FakeClient 延迟提供 race=0 主角数据,**不发**主角信号。无账号和服务器写入。 -- 默认先加载 A1;`--c1` 随后提供 C1 服务器位置,用来验证晚到坐标能纠正地图选择。 -- `gamescene_visual_test.gd` 除位置/地图/表面数外还断言 `feet_gap`:把动画冻结在 0.5 s - 后取全部 MeshInstance3D 世界包围盒的最低点,减去该处地形高度,要求 |gap| ≤ 0.03 m - (R2-01 悬空修复的数值守卫;A1 与 C1 实测均为 0.001373 m)。报告里同时记录 - `feet_y` / `feet_gap` / `feet_gap_max`。 -- 视觉用例输出 `build/rendering/gamescene-/game.png` 与 `report.json`。可用环境变量 `MT_RENDER_OUTPUT` 指定独立目录;复用路径会覆盖同名报告,建议每次新目录。 -- 报告包含位置、相机、地图加载计数、模型表面数、引擎、关键脚本/扩展/模型路径哈希。资产依赖尚未穷举;这只是 R0 部分基线。 -- 截图尺寸为 1280×720,角色待机固定在 0.5 秒。地形/环境动态因素尚未完全冻结,因此暂不设置像素误差硬阈值。 -- 测试不配置学习技能,截图中的空快捷槽不代表技能图标验证成功。 - -## 不启动游戏的模型渲染回归(model_render_test.gd) - -```bash -godot --path project --script model_render_test.gd # 八职业,判定 -godot --path project --script model_render_test.gd -- --race 0 -godot --path project --script model_render_test.gd -- --bless # 重建基线 -``` - -无地图、无网络、无服务器:直接用 `ui/player_view.gd` 离线装配一个角色,`wait` -动画冻结在 0.5 s,固定灯光(平行光 `(-42,155,0)` energy 1.1 + 环境色 -`(0.6,0.62,0.68)` energy 0.8)、洋红背景、512×512。与 `char_bench_test.gd` 的区别 -是它对像素算指标并断言,退出码即结论;bench 仍然只产图供人看。 - -指标(每个 race)与判定方式: - -| 指标 | 含义 | 判定 | -| --- | --- | --- | -| `feet_gap_m` | 最低蒙皮顶点到 y=0 | 绝对,\|gap\| ≤ 0.03 m(实测 −0.0007…0.0030) | -| `silhouette_px` | 正面剪影像素数 | 绝对 ≥ 3000;且对基线 ±3% | -| `head_cover` | 近景机位下发型盖住头部像素的比例(只统计 no_hair 里属于人体的像素) | 对基线 ±0.03 | -| `hair_mean_lum` | 只画发型时发型像素平均亮度 | 对基线 ±0.006 | -| `hair_mean_lum_ctrl` | 同上,但换回 R2-02 修复前的 `StandardMaterial3D(roughness=1)` | 负对照:必须比生产值高 ≥ 0.008 | -| `hair_lum_p99` / `hair_bright_frac` | 亮度 99 分位 / 超过 0.20 的像素占比 | 仅记录,不判定 | - -为什么用平均亮度而不是「有多少像素炸白」:发色本身差别极大(武士近黑 -`mean 0.061`,修罗银白 `mean 0.260`),任何跨职业的绝对亮度上限都不成立。平均 -亮度对「整片发型被额外高光抬亮」敏感,基准差异由按职业记录的基线吸收。 - -负对照不是摆设:它每次都真的用旧材质再渲一遍,保证这条指标现在仍分得开好坏 -(例如有人把灯光调暗到两边都测不出高光时会直接失败)。八职业实测旧材质相对 -新材质的抬升是 +0.0109 … +0.0371,最小值 0.0109 给阈值 0.006/0.008 留了余量。 - -已验证它确实能抓到目标缺陷:把 `Metin2Model::_load_hair()` 临时改回 -`StandardMaterial3D(roughness=1)` 重新编译后,**八个职业全部失败** -(`hair_mean_lum` 偏离基线 0.0109…0.0371);改回 `mtgodot::make_material` 后 -`PASS`。 - -基线在 `test/rendering/model_baseline.json`,含引擎版本。改模型、改发型材质或改 -这套灯光都会让基线失效,需要人工看过渲染结果再 `--bless`,并在 -`docs/RENDERING-REPAIR-PLAN.md` 的执行记录里写清重建原因。 - -覆盖边界:只有离线单角色装配。不含地图、地形接触、真实光照昼夜、装备叠加、 -40250 像素比对,也不能替代 `gamescene_visual_test.gd` 与导出包验收。 - -已知后续:脸部/脚底近景和区域差异器、完整资产哈希、四职业 GR2/GLB 对照、技能状态与图标用例、真实联机及普通 macOS `.app` 验收。 diff --git a/test/rendering/particle-leak/.gitignore b/test/rendering/particle-leak/.gitignore deleted file mode 100644 index 0f103025..00000000 --- a/test/rendering/particle-leak/.gitignore +++ /dev/null @@ -1 +0,0 @@ -.godot/ diff --git a/test/rendering/particle-leak/README.md b/test/rendering/particle-leak/README.md deleted file mode 100644 index e586ccbf..00000000 --- a/test/rendering/particle-leak/README.md +++ /dev/null @@ -1,31 +0,0 @@ -# Particle shader lifetime reproducer - -Independent Godot project: no game assets, autoloads, GDExtension, network, -EffectPlayer, or particles emitter. Eight short-lived ParticleProcessMaterials -cycle through three emission shapes. Weak references must all be dead before -exit; a RenderingServer synchronization also runs before quitting. - -Export this project with the installed macOS template using `--export-release` -and `--export-debug` into separate directories under the repository's `build/`. -Run each exported executable and preserve both stdout and stderr until process -exit. A run is clean only if `ISOLATED_PARTICLE_EXIT alive=0` is present and no -shader/RID error follows. The success marker alone is not a clean-exit verdict. - -2026-09-10, Godot 4.7.1 official, Apple M4/Metal: - -- Release: zero surviving materials, three ParticlesShaderRD/Shader RID leaks. -- Debug: zero surviving materials, no shader/RID leak. - -Logs: `build/rendering/particle-isolated-release.log` and -`build/rendering/particle-isolated-debug.log`. Path overrides (`--main-pack`, -external `--script`) are not supported by the installed release template; -export a standalone app instead. This project contains no copyrighted artwork -and may be used as a minimal upstream reproduction if separately authorized. - -2026-09-11: an unmodified same-revision arm64 release build using -`debug_symbols=yes optimize=speed lto=none vulkan=no` exits cleanly in this -project and in two complete game package checks. Rebuild it with -`bash script/build_macos_release_engine.sh arm64` from the repository root. -This is a verified alternative build path, not proof of a specific compiler -bug or a source fix to the installed official template. Intel and Vulkan are -outside this result. diff --git a/test/rendering/particle-leak/export_presets.cfg b/test/rendering/particle-leak/export_presets.cfg deleted file mode 100644 index 26eeb87b..00000000 --- a/test/rendering/particle-leak/export_presets.cfg +++ /dev/null @@ -1,9 +0,0 @@ -[preset.0] -name="macOS" -platform="macOS" -runnable=true -export_filter="all_resources" -include_filter="" -exclude_filter="" -[preset.0.options] -application/bundle_identifier="org.metin2.particleprobe" diff --git a/test/rendering/particle-leak/main.gd b/test/rendering/particle-leak/main.gd deleted file mode 100644 index 53b057ce..00000000 --- a/test/rendering/particle-leak/main.gd +++ /dev/null @@ -1,17 +0,0 @@ -extends Node -var refs: Array[WeakRef] = [] -func _ready() -> void: call_deferred("run") -func run() -> void: - for i in 8: create_material(i) - for i in 10: await get_tree().process_frame - RenderingServer.force_sync() - var alive := 0 - for reference in refs: - if reference.get_ref() != null: alive += 1 - print("ISOLATED_PARTICLE_EXIT alive=", alive) - get_tree().quit(1 if alive else 0) -func create_material(index: int) -> void: - var material := ParticleProcessMaterial.new() - material.emission_shape = index % 3 - material.get_rid() - refs.append(weakref(material)) diff --git a/test/rendering/particle-leak/main.gd.uid b/test/rendering/particle-leak/main.gd.uid deleted file mode 100644 index 67f463a5..00000000 --- a/test/rendering/particle-leak/main.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://b3vjaypwq6yx6 diff --git a/test/rendering/particle-leak/main.tscn b/test/rendering/particle-leak/main.tscn deleted file mode 100644 index 3735e37f..00000000 --- a/test/rendering/particle-leak/main.tscn +++ /dev/null @@ -1,4 +0,0 @@ -[gd_scene load_steps=2 format=3] -[ext_resource type="Script" path="res://main.gd" id="1"] -[node name="Probe" type="Node"] -script = ExtResource("1") diff --git a/test/rendering/particle-leak/project.godot b/test/rendering/particle-leak/project.godot deleted file mode 100644 index ad78286c..00000000 --- a/test/rendering/particle-leak/project.godot +++ /dev/null @@ -1,9 +0,0 @@ -config_version=5 -[application] -config/name="Particle lifetime isolation" -run/main_scene="res://main.tscn" -[display] -window/size/viewport_width=320 -window/size/viewport_height=240 -[rendering] -textures/vram_compression/import_etc2_astc=true diff --git a/test/run_select_character_test.sh b/test/run_select_character_test.sh deleted file mode 100755 index 8e60b2de..00000000 --- a/test/run_select_character_test.sh +++ /dev/null @@ -1,40 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -cd "$(dirname "$0")/.." -repo="$PWD" -godot_bin="${GODOT:-$(which godot 2>/dev/null || echo "/Applications/Godot.app/Contents/MacOS/Godot")}" -server="$repo/build/extension/src/port/port_fake_login_server" - -server_pid="" -cleanup() { - if [ -n "$server_pid" ]; then - kill -TERM "$server_pid" 2>/dev/null || true - wait "$server_pid" 2>/dev/null || true - fi -} -trap cleanup EXIT - -fifo="/tmp/server.ports" -rm -f "$fifo" -mkfifo "$fifo" 2>/dev/null || touch "$fifo" -"$server" > "$fifo" 2> "/tmp/server.log" & -server_pid=$! -for _ in $(seq 50); do - [ -s "$fifo" ] && break - sleep 0.1 -done -read -r word auth_port game_port < "$fifo" || true -if [ "${word:-}" != "ports" ]; then - echo "Error: fake server did not start" >&2 - exit 1 -fi - -export MT_LIVE_HOST=127.0.0.1 -export MT_LIVE_LOGIN=fakeuser -export MT_LIVE_PASSWORD=fakepass -export MT_LIVE_AUTH_PORT="$auth_port" -export MT_LIVE_GAME_PORT="$game_port" -export MT_LIVE_SLOT=0 - -echo "Running test with Godot: $godot_bin" -"$godot_bin" --path "$repo/project" --script res://test_select_character.gd diff --git a/tools/40250/README.md b/tools/40250/README.md index 5ab3dac9..bf79ad44 100644 --- a/tools/40250/README.md +++ b/tools/40250/README.md @@ -67,12 +67,12 @@ PID 文件中的进程。可用 `MT40250_CHANNELS='1'` 只启动 channel1,或 ## 编译/运行 classic 探针 -FreeBSD 不能启用 Godot extension,但顶层 CMake 提供了 host-only 模式: +顶层 CMake 提供独立的 host-only 协议探针: ```sh cmake -S . -B build-classic \ - -DMTGODOT_BUILD_EXTENSION=OFF \ - -DMTGODOT_BUILD_NET_TOOLS=ON \ + -DMT_BUILD_NET_TOOLS=ON \ + -DMT_BUILD_PORT=OFF \ -DCMAKE_BUILD_TYPE=Release cmake --build build-classic --target net_classic_e2e_host -j2 @@ -81,8 +81,7 @@ build-classic/net_classic_e2e_host 127.0.0.1 13000 test_account test_password 0 探针成功的判据是输出 `PASS PHASE_GAME`,并且打印主角 VID、实体数、背包槽位数 和等级。它不会自动创建角色、交易、购买或移动仓库物品;这些操作属于有副作用的 -fixture 测试,应在测试账号/NPC/第二账号准备后通过 Godot UI 或对应 `M2Client` -intent 逐项操作:好友 add/remove、exchange start/item/gold/accept/cancel、 +fixture 测试,应在测试账号/NPC/第二账号准备后通过原生客户端逐项操作:好友 add/remove、exchange start/item/gold/accept/cancel、 NPC safebox checkin/checkout/move、mall checkout、Cube open/add/delete/make。 ## 40250 配置核对 diff --git a/tools/40250/run_classic_e2e.sh b/tools/40250/run_classic_e2e.sh index 47347806..daa5b4a4 100755 --- a/tools/40250/run_classic_e2e.sh +++ b/tools/40250/run_classic_e2e.sh @@ -1,13 +1,13 @@ #!/bin/sh set -eu -cli=${MTGODOT_CLASSIC_E2E:-} +cli=${MT_CLASSIC_E2E:-${MTGODOT_CLASSIC_E2E:-}} if [ -z "$cli" ]; then - build_root=${MTGODOT_CLASSIC_BUILD:-build-classic} + build_root=${MT_CLASSIC_BUILD:-${MTGODOT_CLASSIC_BUILD:-build-classic}} cli="$build_root/net_classic_e2e_host" fi if [ ! -x "$cli" ]; then - echo "run_classic_e2e.sh: missing $cli; build with -DMTGODOT_BUILD_NET_TOOLS=ON" >&2 + echo "run_classic_e2e.sh: missing $cli; build with -DMT_BUILD_NET_TOOLS=ON" >&2 exit 2 fi