Adapt Android mobile controls and native client UI

This commit is contained in:
shen
2026-09-28 19:39:13 -07:00
parent a1ae69aa7a
commit 3c52ced02e
180 changed files with 688 additions and 23782 deletions
-242
View File
@@ -1,242 +0,0 @@
# mtgodot GDExtension — registers Metin2Model / Metin2AnimPlayer, links libgr2.
# libgr2 is added at the top-level CMakeLists (vendored under ../libgr2), so here
# we just consume the xrender::libgr2 target.
# --- godot-cpp (submodule pinned at 101ae38034304346a46ea9ea84ae156d3e860496) ---
# The parent gitlink is the source-of-truth; .gitmodules intentionally has no
# moving branch. The selected bundle targets Godot 4.7's extension API.
set(GODOTCPP_API_VERSION "4.7" CACHE STRING "Target Godot API version" FORCE)
add_subdirectory(godot-cpp)
# --- vendored native deps: libsodium / libzstd / miniLZO ---
# Built from source (pinned submodules + vendored miniLZO) so the same tree
# compiles on the Android NDK and iOS SDK, which have no Homebrew. Exposes the
# aliases mt3p::sodium / mt3p::zstd / mt3p::minilzo. See docs/THIRD-PARTY.md.
add_subdirectory(third_party)
add_subdirectory(src/codepage)
# --- mtnet: Metin2 net protocol core (no godot-cpp dep; libsodium only) ---
add_library(mtnet STATIC
src/net/text_codec.cpp
src/net/secure_cipher.cpp
src/net/net_stream.cpp
src/net/entity_store.cpp
src/net/mark_image.cpp
src/net/classic/classic_stream.cpp # MT_PROTOCOL=classic (40250)
src/net/classic/classic_parser.cpp
src/net/classic/classic_session.cpp
src/net/classic/classic_cipher.cpp
)
target_include_directories(mtnet PUBLIC src/net)
target_link_libraries(mtnet PUBLIC mt3p::sodium mt3p::minilzo mt3p::cryptopp mt_codepage)
target_compile_features(mtnet PUBLIC cxx_std_20)
# §1.5: non-EUROPE CG_CLIENT_VERSION timestamp, in C __TIMESTAMP__ form
# ("Www Mmm dd hh:mm:ss yyyy"); regenerated on each CMake configure.
string(TIMESTAMP MT_BUILD_TS "%a %b %d %H:%M:%S %Y")
target_compile_definitions(mtnet PRIVATE MT_BUILD_TIMESTAMP="${MT_BUILD_TS}")
# --- mtpack: m2dev-fork asset pack reader/writer (libsodium + zstd) ---
add_library(mtpack STATIC
src/pack/eterpack.cpp
src/pack/pack_mount.cpp
src/pack/asset_source.cpp
)
target_include_directories(mtpack PUBLIC src/pack)
target_link_libraries(mtpack PUBLIC mt3p::sodium mt3p::zstd xrender::formats)
target_compile_features(mtpack PUBLIC cxx_std_20)
# --- mtproto: item_proto / mob_proto reader, 40250 format (CLZO/TEA from port_platform) ---
add_library(mtproto STATIC src/proto/proto.cpp)
target_include_directories(mtproto PUBLIC src/proto)
target_link_libraries(mtproto PUBLIC port_platform)
target_compile_features(mtproto PUBLIC cxx_std_20)
# --- port_logic + port_platform: 40250 mirror (extension/src/port) and its platform layer
# (extension/src/platform), docs/PORT-PLAN.md ---
add_subdirectory(src/port)
# Host-only CLIs + tests: they run on the build machine, so skip them entirely
# when cross-compiling the extension for a device.
set(MT_HOST_BUILD FALSE)
if(CMAKE_SYSTEM_NAME STREQUAL CMAKE_HOST_SYSTEM_NAME)
set(MT_HOST_BUILD TRUE)
endif()
if(MT_HOST_BUILD)
add_executable(packtool tools/packtool.cpp)
target_link_libraries(packtool PRIVATE mtpack)
# net_probe: connect to an auth server, run the handshake + CG_LOGIN3, report.
add_executable(net_probe tools/net_probe.cpp)
target_link_libraries(net_probe PRIVATE mtnet)
# net_e2e: full auth -> game -> char list -> select -> PHASE_GAME against a
# real server; dumps entities / points / inventory. Live integration check.
add_executable(net_e2e tools/net_e2e.cpp)
target_link_libraries(net_e2e PRIVATE mtnet)
# net_classic_e2e: live 40250 classic handshake -> login -> char select -> PHASE_GAME probe.
add_executable(net_classic_e2e tools/net_classic_e2e.cpp)
target_link_libraries(net_classic_e2e PRIVATE mtnet)
endif()
if(BUILD_TESTING AND MT_HOST_BUILD)
add_executable(dxt_decode_test tests/dxt_decode_test.cpp src/dxt.cpp)
target_include_directories(dxt_decode_test PRIVATE src)
add_test(NAME dxt.decode COMMAND $<TARGET_FILE:dxt_decode_test>)
add_executable(net_cipher_test tests/net_cipher_test.cpp)
target_link_libraries(net_cipher_test PRIVATE mtnet)
add_test(NAME net.cipher_roundtrip COMMAND $<TARGET_FILE:net_cipher_test>)
add_executable(net_loopback_test tests/net_loopback_test.cpp)
target_link_libraries(net_loopback_test PRIVATE mtnet)
add_test(NAME net.loopback_flow COMMAND $<TARGET_FILE:net_loopback_test>)
add_executable(net_entity_test tests/net_entity_test.cpp)
target_link_libraries(net_entity_test PRIVATE mtnet)
add_test(NAME net.entity_store COMMAND $<TARGET_FILE:net_entity_test>)
add_executable(net_state_queue_test tests/net_state_queue_test.cpp)
target_link_libraries(net_state_queue_test PRIVATE mtnet)
add_test(NAME net.state_queue COMMAND $<TARGET_FILE:net_state_queue_test>)
# 40250 "classic" backend (MT_PROTOCOL=classic).
add_executable(net_classic_wire_test tests/net_classic_wire_test.cpp)
target_include_directories(net_classic_wire_test PRIVATE src/net)
target_compile_features(net_classic_wire_test PRIVATE cxx_std_20)
add_test(NAME net.classic_wire COMMAND $<TARGET_FILE:net_classic_wire_test>)
add_executable(net_classic_stream_test tests/net_classic_stream_test.cpp)
target_link_libraries(net_classic_stream_test PRIVATE mtnet)
add_test(NAME net.classic_stream COMMAND $<TARGET_FILE:net_classic_stream_test>)
add_executable(net_classic_session_test tests/net_classic_session_test.cpp)
target_link_libraries(net_classic_session_test PRIVATE mtnet)
add_test(NAME net.classic_session COMMAND $<TARGET_FILE:net_classic_session_test>)
add_executable(net_classic_cipher_test tests/net_classic_cipher_test.cpp)
target_link_libraries(net_classic_cipher_test PRIVATE mtnet)
add_test(NAME net.classic_cipher COMMAND $<TARGET_FILE:net_classic_cipher_test>)
add_executable(net_classic_encstream_test tests/net_classic_encstream_test.cpp)
target_link_libraries(net_classic_encstream_test PRIVATE mtnet)
add_test(NAME net.classic_encstream COMMAND $<TARGET_FILE:net_classic_encstream_test>)
add_executable(net_classic_mark_test tests/net_classic_mark_test.cpp)
target_link_libraries(net_classic_mark_test PRIVATE mtnet)
add_test(NAME net.classic_mark COMMAND $<TARGET_FILE:net_classic_mark_test>)
add_executable(net_text_codec_test tests/net_text_codec_test.cpp)
target_link_libraries(net_text_codec_test PRIVATE mtnet)
add_test(NAME net.text_codec COMMAND $<TARGET_FILE:net_text_codec_test>)
add_executable(net_bounds_test tests/net_bounds_test.cpp)
target_link_libraries(net_bounds_test PRIVATE mtnet)
add_test(NAME net.bounds COMMAND $<TARGET_FILE:net_bounds_test>)
add_executable(net_mark_test tests/net_mark_test.cpp)
target_link_libraries(net_mark_test PRIVATE mtnet mt3p::minilzo)
add_test(NAME net.guild_mark COMMAND $<TARGET_FILE:net_mark_test>)
add_executable(pack_roundtrip_test tests/pack_roundtrip_test.cpp)
target_link_libraries(pack_roundtrip_test PRIVATE mtpack)
add_test(NAME pack.roundtrip COMMAND $<TARGET_FILE:pack_roundtrip_test>)
add_executable(proto_test tests/proto_test.cpp)
target_link_libraries(proto_test PRIVATE mtproto)
# MT_40250_CLIENT comes from src/port/CMakeLists.txt; skipped (77) without the client, failed under
# MT_ASSETS_STRICT=1.
add_test(NAME proto.item_mob COMMAND $<TARGET_FILE:proto_test> ${MT_40250_CLIENT})
set_tests_properties(proto.item_mob PROPERTIES SKIP_RETURN_CODE 77)
add_executable(proto_item_layout_test tests/proto_item_layout_test.cpp)
target_link_libraries(proto_item_layout_test PRIVATE mtproto)
add_test(NAME proto.item_layout COMMAND $<TARGET_FILE:proto_item_layout_test>)
# The embedded interpreter, when third_party built it (-DMTGODOT_EMBED_PYTHON=ON).
if(TARGET mtpython)
add_executable(py_embed_test tests/py_embed_test.cpp)
target_link_libraries(py_embed_test PRIVATE mtpython)
add_dependencies(py_embed_test mtpython_stdlib)
# The stdlib exactly as the host ships it: python27.zip, read through zipimport (批次 2P step 3).
add_test(NAME python.embed COMMAND $<TARGET_FILE:py_embed_test> "${MT_PYTHON_STDLIB_ZIP}")
endif()
endif()
# --- the extension library ---
# macOS/Android: SHARED (Godot dlopen()s it). iOS: STATIC — the platform forbids
# loading dynamic libraries, so the archive is linked into the app at export time
# (see docs/PLATFORMS.md for the remaining F1 export/sign steps).
if(CMAKE_SYSTEM_NAME STREQUAL "iOS")
set(MT_LIB_KIND STATIC)
set(MT_PLAT_TAG "ios")
elseif(CMAKE_SYSTEM_NAME STREQUAL "Android")
set(MT_LIB_KIND SHARED)
set(MT_PLAT_TAG "android")
else()
set(MT_LIB_KIND SHARED)
set(MT_PLAT_TAG "macos")
endif()
add_library(mtgodot ${MT_LIB_KIND}
src/register_types.cpp
src/metin2_model.cpp
src/metin2_anim.cpp
src/metin2_world.cpp
src/terrain_splat.cpp
src/static_object.cpp
src/tree_placeholder.cpp
src/environment_builder.cpp
src/water_builder.cpp
src/gr2_bridge.cpp
src/m2_material.cpp
src/dxt.cpp
src/asset_io.cpp
src/texture_util.cpp
src/net/m2_client.cpp
src/proto/proto_node.cpp
src/pack40250_node.cpp
src/python_stdlib.cpp
src/python_host_node.cpp
)
target_compile_features(mtgodot PRIVATE cxx_std_20)
target_link_libraries(mtgodot PRIVATE godot::cpp xrender::libgr2 xrender::formats mtnet mtproto port_platform)
# The embedded interpreter's standard library (批次 2P step 3c). python27.zip and its digest are
# copied next to project.godot so the exporter packs them into the APK/IPA (the export presets'
# include_filter lists them); src/python_stdlib.cpp stages them out of the PCK at runtime.
if(TARGET mtpython_stdlib)
target_compile_definitions(mtgodot PRIVATE MTGODOT_HAVE_PYTHON)
# A target rather than a custom command on the two files: the command that produces them lives in
# third_party/, and a Makefile generator only resolves a generated-file dependency inside the
# directory that declared it. copy_if_different keeps the no-op build a compare.
add_custom_target(mtpython_stdlib_project ALL
COMMAND ${CMAKE_COMMAND} -E copy_if_different
"${MT_PYTHON_STDLIB_ZIP}" "${CMAKE_SOURCE_DIR}/project/python27.zip"
COMMAND ${CMAKE_COMMAND} -E copy_if_different
"${MT_PYTHON_STDLIB_SHA}" "${CMAKE_SOURCE_DIR}/project/python27.zip.sha256"
COMMENT "Copying python27.zip into project/ (res://)")
add_dependencies(mtpython_stdlib_project mtpython_stdlib)
add_dependencies(mtgodot mtpython_stdlib_project)
endif()
# Godot's .gdextension expects, per platform:
# project/bin/libmtgodot.macos.template_{debug,release}.dylib
# project/bin/libmtgodot.ios.template_{debug,release}.a
# project/bin/libmtgodot.android.template_{debug,release}.<arch>.so
set(MT_CFG_TAG "$<IF:$<CONFIG:Release>,template_release,template_debug>")
if(CMAKE_SYSTEM_NAME STREQUAL "Android")
# godot_arch_name-style suffix; OnePlus 13 = arm64.
set(MT_ARCH_SUFFIX ".${CMAKE_ANDROID_ARCH_ABI}")
string(REPLACE "arm64-v8a" "arm64" MT_ARCH_SUFFIX "${MT_ARCH_SUFFIX}")
else()
set(MT_ARCH_SUFFIX "")
endif()
set_target_properties(mtgodot PROPERTIES
PREFIX "lib"
OUTPUT_NAME "mtgodot.${MT_PLAT_TAG}.${MT_CFG_TAG}${MT_ARCH_SUFFIX}"
ARCHIVE_OUTPUT_DIRECTORY "${CMAKE_SOURCE_DIR}/project/bin"
LIBRARY_OUTPUT_DIRECTORY "${CMAKE_SOURCE_DIR}/project/bin"
)
-72
View File
@@ -1,72 +0,0 @@
#include "asset_io.h"
#include <godot_cpp/classes/file_access.hpp>
#include "platform/PackBackend.h"
#include <cstring>
#include <string>
#include <vector>
using namespace godot;
namespace mtgodot {
namespace {
bool pack_name(const String &path, std::string &name) {
if (!path.begins_with(PACK_SCHEME)) {
return false;
}
name = path.substr(String(PACK_SCHEME).length()).utf8().get_data();
return true;
}
} // namespace
PackedByteArray read_file(const String &path) {
if (path.is_empty()) {
return PackedByteArray();
}
std::string name;
if (pack_name(path, name)) {
std::vector<uint8_t> bytes;
PackedByteArray out;
if (mtpack40250::read(name, bytes)) {
out.resize((int64_t)bytes.size());
if (!bytes.empty()) {
std::memcpy(out.ptrw(), bytes.data(), bytes.size());
}
}
return out;
}
// FileAccess::get_file_as_bytes handles res:// / user:// / absolute OS paths.
return FileAccess::get_file_as_bytes(path);
}
bool file_exists(const String &path) {
std::string name;
if (pack_name(path, name)) {
return mtpack40250::exists(name);
}
return !path.is_empty() && FileAccess::file_exists(path);
}
Image dds_from_file(const String &path) {
PackedByteArray b = read_file(path);
if (b.is_empty()) {
return Image{};
}
return load_dds(b.ptr(), (size_t)b.size());
}
std::optional<gr2::File> gr2_from_file(const String &path, gr2::LoadError *err) {
PackedByteArray b = read_file(path);
if (b.is_empty()) {
if (err) {
*err = {"open", std::string("cannot read ") + path.utf8().get_data()};
}
return std::nullopt;
}
return gr2::File::load(b.ptr(), (size_t)b.size(), err);
}
} // namespace mtgodot
-38
View File
@@ -1,38 +0,0 @@
// asset_io — the one portable asset-read point.
//
// The standalone libs (libgr2, formats, mtproto, mtpack) keep their raw
// fopen/ifstream `*_path()` entry points for the non-Godot CTests. Every read
// from the *running extension* goes through here instead, so it works for:
// - res:// (loose files in dev, PCK on iOS/Android read-only bundles)
// - user:// (extracted cache)
// - absolute OS paths (dev: AssetRoot points at mtgodot-poc/assets/)
// godot::FileAccess handles all three transparently.
// - pack://<40250 path> (e.g. pack://locale/en/item_proto, pack://d:/ymir work/pc/...): the 40250 packs,
// read through the ported CEterPackManager once Metin2Pack.initialize(<Client dir>) has run
// (platform/PackBackend.h). The path after pack:// is passed as UTF-8.
#pragma once
#include <godot_cpp/variant/packed_byte_array.hpp>
#include <godot_cpp/variant/string.hpp>
#include <gr2/gr2.h>
#include <optional>
#include "dxt.h"
namespace mtgodot {
inline constexpr const char *PACK_SCHEME = "pack://";
// Whole-file read. Empty PackedByteArray on failure (path missing / unreadable).
godot::PackedByteArray read_file(const godot::String &path);
bool file_exists(const godot::String &path); // FileAccess::file_exists wrapper
// .dds -> RGBA8 level 0 via read_file. !ok() on failure.
Image dds_from_file(const godot::String &path);
// .gr2 parse via read_file. nullopt on read or parse failure.
std::optional<gr2::File> gr2_from_file(const godot::String &path, gr2::LoadError *err = nullptr);
} // namespace mtgodot
+2 -2
View File
@@ -4,7 +4,7 @@
#include <algorithm>
#include <cstring>
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
+3 -3
View File
@@ -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 <cstdint>
#include <vector>
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
-451
View File
@@ -1,451 +0,0 @@
#include "environment_builder.h"
#include "asset_io.h"
#include "dxt.h"
#include <godot_cpp/classes/environment.hpp>
#include <godot_cpp/classes/image.hpp>
#include <godot_cpp/classes/image_texture.hpp>
#include <godot_cpp/classes/procedural_sky_material.hpp>
#include <godot_cpp/classes/shader.hpp>
#include <godot_cpp/classes/shader_material.hpp>
#include <godot_cpp/classes/sky.hpp>
#include <godot_cpp/classes/texture2d.hpp>
#include <godot_cpp/core/object.hpp>
#include <m2_coord.h>
#include <algorithm>
using namespace godot;
namespace mtgodot {
namespace {
Color rgba(const fmt::Rgba &c) { return Color(c[0], c[1], c[2], c[3]); }
float luma(const fmt::Rgba &c) { return 0.2126f * c[0] + 0.7152f * c[1] + 0.0722f * c[2]; }
const char *SRC_SKYBOX = R"(shader_type sky;
render_mode use_debanding;
uniform sampler2D front_tex : source_color, filter_linear_mipmap_anisotropic;
uniform sampler2D back_tex : source_color, filter_linear_mipmap_anisotropic;
uniform sampler2D left_tex : source_color, filter_linear_mipmap_anisotropic;
uniform sampler2D right_tex : source_color, filter_linear_mipmap_anisotropic;
uniform sampler2D top_tex : source_color, filter_linear_mipmap_anisotropic;
uniform sampler2D bottom_tex : source_color, filter_linear_mipmap_anisotropic;
uniform bool has_front = false;
uniform bool has_back = false;
uniform bool has_left = false;
uniform bool has_right = false;
uniform bool has_top = false;
uniform bool has_bottom = false;
uniform vec4 gradient_top = vec4(0.2, 0.3, 0.6, 1.0);
uniform vec4 gradient_horizon = vec4(0.5, 0.6, 0.8, 1.0);
uniform vec4 gradient_bottom = vec4(0.2, 0.2, 0.25, 1.0);
uniform sampler2D cloud_tex : source_color, repeat_enable, filter_linear_mipmap_anisotropic;
uniform bool has_cloud = false;
uniform vec2 cloud_scale = vec2(2000.0, 2000.0);
uniform float cloud_height = 300.0;
uniform vec2 cloud_texture_scale = vec2(4.0, 4.0);
uniform vec2 cloud_speed = vec2(0.001, 0.001);
uniform vec4 cloud_tint = vec4(0.0, 0.0, 0.0, 0.0);
vec3 gradient_color(vec3 direction) {
float height = clamp(direction.y * 0.5 + 0.5, 0.0, 1.0);
if (height < 0.5)
return mix(gradient_bottom.rgb, gradient_horizon.rgb, height * 2.0);
return mix(gradient_horizon.rgb, gradient_top.rgb, (height - 0.5) * 2.0);
}
vec3 apply_cloud(vec3 color, vec3 direction) {
if (!has_cloud || direction.y <= 0.001 || cloud_height <= 0.0 ||
cloud_scale.x <= 0.001 || cloud_scale.y <= 0.001)
return color;
// ClientVS22 renders a finite horizontal quad at camera.z + CloudHeight.
// After the Metin2 -> Godot conversion this is camera.y + height. The
// source quad maps source +Y -> U=0 and source +X -> V=1; source Y is
// Godot -Z, so keep that orientation here.
float distance_to_cloud = cloud_height / direction.y;
vec2 plane = direction.xz * distance_to_cloud;
if (abs(plane.x) > cloud_scale.x || abs(plane.y) > cloud_scale.y)
return color;
vec2 uv = vec2(
0.5 + 0.5 * plane.y / cloud_scale.y,
0.5 + 0.5 * plane.x / cloud_scale.x);
uv = fract(uv * cloud_texture_scale + TIME * cloud_speed);
// SkyBox.cpp uses MODULATEINVALPHA_ADDCOLOR followed by ONE /
// INVSRCCOLOR blending: cloud.rgb + sky * (1 - cloud.rgb).
vec4 texel = texture(cloud_tex, uv);
vec3 cloud_rgb = clamp(texel.rgb * (1.0 - cloud_tint.a) + cloud_tint.rgb,
0.0, 1.0);
return cloud_rgb + color * (1.0 - cloud_rgb);
}
void sky() {
vec3 direction = normalize(EYEDIR);
vec3 color = gradient_color(direction);
float ax = abs(direction.x);
float ay = abs(direction.y);
float az = abs(direction.z);
vec2 uv;
// ClientVS22 uses Metin2 Z-up faces. After x,z,-y conversion:
// front=+Z, back=-Z, left=+X, right=-X, top=+Y, bottom=-Y.
if (az >= ax && az >= ay) {
if (direction.z > 0.0) {
uv = vec2(0.5 - 0.5 * direction.x / az,
0.5 - 0.5 * direction.y / az);
if (has_front)
color = texture(front_tex, uv).rgb;
} else {
uv = vec2(0.5 + 0.5 * direction.x / az,
0.5 - 0.5 * direction.y / az);
if (has_back)
color = texture(back_tex, uv).rgb;
}
} else if (ax >= ay) {
if (direction.x > 0.0) {
uv = vec2(0.5 + 0.5 * direction.z / ax,
0.5 - 0.5 * direction.y / ax);
if (has_left)
color = texture(left_tex, uv).rgb;
} else {
uv = vec2(0.5 - 0.5 * direction.z / ax,
0.5 - 0.5 * direction.y / ax);
if (has_right)
color = texture(right_tex, uv).rgb;
}
} else if (direction.y > 0.0) {
uv = vec2(0.5 - 0.5 * direction.x / ay,
0.5 - 0.5 * direction.z / ay);
if (has_top)
color = texture(top_tex, uv).rgb;
} else {
uv = vec2(0.5 - 0.5 * direction.x / ay,
0.5 - 0.5 * direction.z / ay);
if (has_bottom)
color = texture(bottom_tex, uv).rgb;
}
COLOR = apply_cloud(color, direction);
}
)";
Ref<ImageTexture> solid_texture(const Color &color) {
PackedByteArray pixels;
pixels.resize(4);
uint8_t *p = pixels.ptrw();
p[0] = (uint8_t)std::round(std::clamp(color.r, 0.0f, 1.0f) * 255.0f);
p[1] = (uint8_t)std::round(std::clamp(color.g, 0.0f, 1.0f) * 255.0f);
p[2] = (uint8_t)std::round(std::clamp(color.b, 0.0f, 1.0f) * 255.0f);
p[3] = (uint8_t)std::round(std::clamp(color.a, 0.0f, 1.0f) * 255.0f);
Ref<godot::Image> image = godot::Image::create_from_data(
1, 1, false, godot::Image::FORMAT_RGBA8, pixels);
image->generate_mipmaps();
return ImageTexture::create_from_image(image);
}
Ref<ImageTexture> load_texture(const std::string &name, const fmt::AssetResolver *resolver) {
if (name.empty() || resolver == nullptr)
return Ref<ImageTexture>();
const std::string path = resolver->resolve(name, nullptr);
if (path.empty())
return Ref<ImageTexture>();
Ref<godot::Image> image;
const String godot_path(path.c_str());
if (godot_path.get_extension().to_lower() == "dds") {
const mtgodot::Image decoded = mtgodot::dds_from_file(godot_path);
if (!decoded.ok())
return Ref<ImageTexture>();
PackedByteArray pixels;
pixels.resize((int64_t)decoded.rgba.size());
std::copy(decoded.rgba.begin(), decoded.rgba.end(), pixels.ptrw());
image = godot::Image::create_from_data(decoded.w, decoded.h, false,
godot::Image::FORMAT_RGBA8, pixels);
} else {
// Image::load_from_file cannot open the pack:// virtual filesystem.
if (godot_path.begins_with("pack://")) {
PackedByteArray bytes = mtgodot::read_file(godot_path);
image.instantiate();
if (godot_path.get_extension().to_lower() == "tga")
image->load_tga_from_buffer(bytes);
else if (godot_path.get_extension().to_lower() == "png")
image->load_png_from_buffer(bytes);
else if (godot_path.get_extension().to_lower() == "jpg")
image->load_jpg_from_buffer(bytes);
} else {
image = godot::Image::load_from_file(godot_path);
}
}
if (image.is_null() || image->is_empty())
return Ref<ImageTexture>();
image->generate_mipmaps();
return ImageTexture::create_from_image(image);
}
} // namespace
EnvNodes apply_environment(const fmt::Environment &env, Node *parent,
const fmt::AssetResolver *resolver) {
EnvNodes out;
// --- DirectionalLight (Background) ---
out.sun = Object::cast_to<DirectionalLight3D>(parent->get_node_or_null(NodePath("Sun")));
if (!out.sun) {
out.sun = memnew(DirectionalLight3D);
out.sun->set_name("Sun");
parent->add_child(out.sun);
}
out.sun->set_cull_mask(1u << 0); // background / terrain layer
out.sun->set_visible(env.dir_light.bg_enable);
// .msenv Direction 是 Metin2 Z-up 向量(光传播方向)。
fmt::m2coord::Vec3 d = fmt::m2coord::direction_to_godot(
env.dir_light.direction[0], env.dir_light.direction[1], env.dir_light.direction[2]);
Vector3 fwd(d.x, d.y, d.z);
if (fwd.length() < 1e-4f)
fwd = Vector3(-0.4f, -0.7f, -0.55f);
fwd.normalize();
// Godot 光沿自身 -Z 照射 -> basis 的 -Z = fwd
Basis b = Basis::looking_at(fwd, Vector3(0, 1, 0));
out.sun->set_transform(Transform3D(b, Vector3(0, 0, 0)));
if (env.dir_light.bg_enable || luma(env.dir_light.bg_diffuse) > 0.01f) {
out.sun->set_color(rgba(env.dir_light.bg_diffuse));
out.sun->set_param(Light3D::PARAM_ENERGY,
std::clamp(0.9f + luma(env.dir_light.bg_diffuse) * 0.4f, 0.7f, 1.6f));
}
out.sun->set_shadow(true);
out.sun->set_param(Light3D::PARAM_SHADOW_NORMAL_BIAS, 2.0f);
out.sun->set_param(Light3D::PARAM_SHADOW_BIAS, 0.06f);
out.sun->set_param(Light3D::PARAM_SHADOW_MAX_DISTANCE, 500.0f);
out.sun->set_param(Light3D::PARAM_SHADOW_SPLIT_1_OFFSET, 0.08f);
out.sun->set_param(Light3D::PARAM_SHADOW_SPLIT_2_OFFSET, 0.22f);
out.sun->set_param(Light3D::PARAM_SHADOW_SPLIT_3_OFFSET, 0.5f);
out.sun->set_shadow_mode(DirectionalLight3D::SHADOW_PARALLEL_4_SPLITS);
// 角色/物体的间接补光:暖色,能量取自 Material.Ambient
out.sun->set_param(Light3D::PARAM_SPECULAR, 0.4f);
// ClientVS22 keeps the character light separate from the background light
// (MapOutdoor::OnBeginEnvironment -> SpeedTree::SetLight). Godot's light
// cull mask is the closest exact scene-level equivalent: static world
// geometry is layer 1, Metin2Model geometry is layer 2.
out.character_light = Object::cast_to<DirectionalLight3D>(
parent->get_node_or_null(NodePath("CharacterLight")));
if (!out.character_light) {
out.character_light = memnew(DirectionalLight3D);
out.character_light->set_name("CharacterLight");
parent->add_child(out.character_light);
}
out.character_light->set_cull_mask(1u << 1);
out.character_light->set_visible(env.dir_light.ch_enable);
out.character_light->set_transform(Transform3D(b, Vector3(0, 0, 0)));
out.character_light->set_color(rgba(env.dir_light.ch_diffuse));
out.character_light->set_param(Light3D::PARAM_ENERGY,
std::clamp(0.9f + luma(env.dir_light.ch_diffuse) * 0.4f, 0.7f, 1.6f));
out.character_light->set_shadow(false);
out.character_light->set_param(Light3D::PARAM_SPECULAR, 0.4f);
// --- WorldEnvironment ---
out.world_env =
Object::cast_to<WorldEnvironment>(parent->get_node_or_null(NodePath("WorldEnv")));
if (!out.world_env) {
out.world_env = memnew(WorldEnvironment);
out.world_env->set_name("WorldEnv");
parent->add_child(out.world_env);
}
Ref<godot::Environment> e = out.world_env->get_environment();
if (e.is_null())
e.instantiate();
// 天空:SkyBox Gradient -> ProceduralSky 的三段色(zenith / horizon / ground)。
// 参考端的 texture mode 是独立六面 quad;Godot Sky shader 用 EYEDIR 采样
// 同一套六面图,坐标和 UV 依照 EterLib/SkyBox.cpp 的面顶点顺序转换。
int sky_face_count = 0;
bool sky_texture_applied = false;
bool sky_cloud_applied = false;
{
Ref<ProceduralSkyMaterial> psm;
psm.instantiate();
const auto &g = env.sky.gradient;
if (g.size() >= 2) {
psm->set_sky_top_color(rgba(g.front()));
psm->set_sky_horizon_color(rgba(g[g.size() / 2]));
psm->set_ground_horizon_color(rgba(g.back()));
Color gb = rgba(g.back());
psm->set_ground_bottom_color(Color(gb.r * 0.6f, gb.g * 0.6f, gb.b * 0.65f));
}
psm->set_sun_angle_max(6.0f);
Ref<Sky> sky;
sky.instantiate();
std::array<Ref<ImageTexture>, 6> faces;
std::array<bool, 6> face_loaded{};
for (int i = 0; i < 6; ++i) {
if (env.sky.face_textures[i].empty())
continue;
faces[i] = load_texture(env.sky.face_textures[i], resolver);
face_loaded[i] = faces[i].is_valid();
if (face_loaded[i])
++sky_face_count;
}
Ref<ImageTexture> cloud_texture;
if (!env.sky.cloud_texture.empty())
cloud_texture = load_texture(env.sky.cloud_texture, resolver);
const bool use_texture_sky = env.sky.texture_render_mode && sky_face_count > 0;
sky_texture_applied = use_texture_sky;
if (use_texture_sky) {
Ref<Shader> shader;
shader.instantiate();
shader->set_code(String(SRC_SKYBOX));
Ref<ShaderMaterial> material;
material.instantiate();
material->set_shader(shader);
Color fallback = g.empty() ? Color(0.35f, 0.45f, 0.7f, 1.0f) : rgba(g.front());
Color horizon = g.size() < 2 ? fallback : rgba(g[g.size() / 2]);
Color ground = g.empty() ? Color(0.2f, 0.2f, 0.25f, 1.0f) : rgba(g.back());
material->set_shader_parameter("gradient_top", fallback);
material->set_shader_parameter("gradient_horizon", horizon);
material->set_shader_parameter("gradient_bottom", ground);
static constexpr const char *kFaceParams[6] = {
"front_tex", "back_tex", "left_tex", "right_tex", "top_tex", "bottom_tex"};
static constexpr const char *kFaceFlags[6] = {
"has_front", "has_back", "has_left", "has_right", "has_top", "has_bottom"};
for (int i = 0; i < 6; ++i) {
if (!faces[i].is_valid())
faces[i] = solid_texture(i == 5 ? ground : horizon);
material->set_shader_parameter(kFaceParams[i], faces[i]);
material->set_shader_parameter(kFaceFlags[i], face_loaded[i]);
}
if (cloud_texture.is_valid()) {
material->set_shader_parameter("cloud_tex", cloud_texture);
material->set_shader_parameter("has_cloud", true);
material->set_shader_parameter("cloud_scale", Vector2(
std::max(env.sky.cloud_scale[0] * (float)fmt::m2coord::CM_TO_M, 0.01f),
std::max(env.sky.cloud_scale[1] * (float)fmt::m2coord::CM_TO_M, 0.01f)));
material->set_shader_parameter("cloud_height",
std::max(env.sky.cloud_height * (float)fmt::m2coord::CM_TO_M, 0.01f));
material->set_shader_parameter("cloud_texture_scale", Vector2(
env.sky.cloud_texture_scale[0], env.sky.cloud_texture_scale[1]));
material->set_shader_parameter("cloud_speed", Vector2(
env.sky.cloud_speed[0], env.sky.cloud_speed[1]));
Color tint = Color(0, 0, 0, 0);
if (!env.sky.cloud_color.empty())
tint = rgba(env.sky.cloud_color.front());
material->set_shader_parameter("cloud_tint", tint);
sky_cloud_applied = true;
}
sky->set_material(material);
} else {
sky->set_material(psm);
}
// Reference SkyBox scrolls clouds in its own renderer. Godot's procedural
// sky has no equivalent UV-speed control, but sky_cover preserves the real
// cloud asset and alpha/color instead of silently dropping CloudTextureFileName.
if (!env.sky.cloud_texture.empty() && !use_texture_sky) {
if (cloud_texture.is_valid()) {
psm->set_sky_cover(cloud_texture);
Color tint = Color(1, 1, 1, 1);
if (!env.sky.cloud_color.empty())
tint = rgba(env.sky.cloud_color.front());
psm->set_sky_cover_modulate(tint);
sky_cloud_applied = true;
}
}
e->set_sky(sky);
e->set_background(godot::Environment::BG_SKY);
}
// 环境光:Material.Ambient 定色 + 亮度(Metin2 用暖色环境光提亮阴影面)
e->set_ambient_source(godot::Environment::AMBIENT_SOURCE_COLOR);
Color amb = rgba(env.material.ambient);
e->set_ambient_light_color(amb);
e->set_ambient_light_energy(std::clamp(0.5f + luma(env.material.ambient) * 0.4f, 0.35f, 0.95f));
e->set_ambient_light_sky_contribution(0.35f);
// Emissive 当作全局轻微自发光提亮(避免死黑)
e->set_bg_energy_multiplier(1.0f);
// 雾:优先用 .msenv 的 NearDistance/FarDistance(cm,A1 = 5000/20000 -> 50/200m
// 太近,客户端 D3DFOG 实际按更大的世界尺度;乘一个系数放到远景轻霭区)。没给
// 距离就退回 foglevel 启发式。参考端是线性远景雾 + 天空同色,不是浓雾。
if (env.fog.enable) {
e->set_fog_enabled(true);
e->set_fog_light_color(rgba(env.fog.color));
e->set_fog_mode(godot::Environment::FOG_MODE_DEPTH);
float begin_m, end_m;
if (env.fog.near_distance > 1.0f && env.fog.far_distance > env.fog.near_distance) {
begin_m = env.fog.near_distance * (float)fmt::m2coord::CM_TO_M;
end_m = env.fog.far_distance * (float)fmt::m2coord::CM_TO_M;
// 客户端摄距比我们远:把近雾往后推一截,别糊住中景
begin_m = std::max(begin_m, 120.0f);
end_m = std::max(end_m, begin_m + 400.0f);
} else {
float fl = env.fog.fog_level > 0 ? float(env.fog.fog_level) : 4.0f;
begin_m = std::clamp((11.0f - fl) * 45.0f, 60.0f, 500.0f);
end_m = begin_m + 700.0f;
}
e->set_fog_depth_begin(begin_m);
e->set_fog_depth_end(end_m);
e->set_fog_depth_curve(0.5f);
e->set_fog_density(0.0f);
e->set_fog_sky_affect(0.0f); // 天空自己是渐变,不要被雾再洗一层
e->set_fog_sun_scatter(0.05f);
} else {
e->set_fog_enabled(false);
}
// 色调:参考端是 DX9 定功能、LDR、无 tonemap/HDR。用 LINEAR + 曝光 1 最贴近,
// Filmic 会抬黑、降饱和 -> 画面发灰。轻微加饱和/对比补回胶片感。
e->set_tonemapper(godot::Environment::TONE_MAPPER_LINEAR);
e->set_tonemap_exposure(1.0f);
e->set_adjustment_enabled(true);
e->set_adjustment_saturation(1.12f);
e->set_adjustment_contrast(1.05f);
e->set_adjustment_brightness(1.0f);
// glow:参考端没有 bloom。留极轻的,只有真过曝才溢。
e->set_glow_enabled(true);
e->set_glow_intensity(0.06f);
e->set_glow_strength(0.7f);
e->set_glow_bloom(0.0f);
e->set_glow_hdr_bleed_threshold(1.6f);
// SSAO:参考端把接触阴影烘进地形阴影贴图。这里用一点实时 SSAO 代替。
e->set_ssao_enabled(true);
e->set_ssao_radius(1.2f);
e->set_ssao_intensity(0.9f);
// Keep parsed reference values visible to the runtime/debug probe even where
// Godot has no 1:1 mapping (six cube faces, D3D blend factors, cloud motion,
// and lens flare). This also prevents a future builder from silently losing
// the fields while the renderer equivalent is being implemented.
out.world_env->set_meta("msenv_sky_texture_mode", env.sky.texture_render_mode);
out.world_env->set_meta("msenv_sky_face_count", sky_face_count);
out.world_env->set_meta("msenv_sky_texture_mode_applied", sky_texture_applied);
out.world_env->set_meta("msenv_sky_cloud_applied", sky_cloud_applied);
out.world_env->set_meta("msenv_sky_scale",
Vector3(env.sky.scale[0], env.sky.scale[1], env.sky.scale[2]));
out.world_env->set_meta("msenv_cloud_scale",
Vector2(env.sky.cloud_scale[0], env.sky.cloud_scale[1]));
out.world_env->set_meta("msenv_cloud_height", env.sky.cloud_height);
out.world_env->set_meta("msenv_cloud_texture_scale",
Vector2(env.sky.cloud_texture_scale[0], env.sky.cloud_texture_scale[1]));
out.world_env->set_meta("msenv_cloud_speed",
Vector2(env.sky.cloud_speed[0], env.sky.cloud_speed[1]));
out.world_env->set_meta("msenv_filter_enabled", env.filter.enable);
out.world_env->set_meta("msenv_filter_color", rgba(env.filter.color));
out.world_env->set_meta("msenv_filter_alpha_src", env.filter.alpha_src);
out.world_env->set_meta("msenv_filter_alpha_dest", env.filter.alpha_dest);
out.world_env->set_meta("msenv_lens_flare_enabled", env.lens_flare.enable);
out.world_env->set_meta("msenv_lens_flare_texture", env.lens_flare.main_flare_texture.c_str());
out.world_env->set_meta("msenv_background_light_enabled", env.dir_light.bg_enable);
out.world_env->set_meta("msenv_character_light_enabled", env.dir_light.ch_enable);
out.world_env->set_meta("msenv_background_light_ambient", rgba(env.dir_light.bg_ambient));
out.world_env->set_meta("msenv_character_light_ambient", rgba(env.dir_light.ch_ambient));
// MapUtil.cpp defaults; Environment_Load has no wind token to override these.
out.world_env->set_meta("msenv_wind_strength", 0.2f);
out.world_env->set_meta("msenv_wind_random", 0.0f);
out.world_env->set_environment(e);
return out;
}
} // namespace mtgodot
-26
View File
@@ -1,26 +0,0 @@
#pragma once
#include <godot_cpp/classes/directional_light3d.hpp>
#include <godot_cpp/classes/world_environment.hpp>
#include <asset_resolver.h>
#include <environment.h>
// W5 —— .msenv(已由 formats/environment 解析)-> Godot 光照 / 天空 / 雾 / 色调。
// SHINSOO §9-W5。背景 / 角色方向光按参考端分成两个可见性层;云贴图接入
// ProceduralSkyMaterial 的 sky cover;texture-mode 下再用 Godot sky shader 叠加
// 参考端的有限云层平面与 UV 滚动。六面天空、滤色和 lens flare 仍保持显式待办,
// 不把不等价的近似标成完成。
namespace mtgodot {
struct EnvNodes {
godot::DirectionalLight3D *sun = nullptr;
godot::DirectionalLight3D *character_light = nullptr;
godot::WorldEnvironment *world_env = nullptr;
};
// 在 parent 下建 / 配 DirectionalLight3D + WorldEnvironment。已存在则复用。
EnvNodes apply_environment(const fmt::Environment &env, godot::Node *parent,
const fmt::AssetResolver *resolver = nullptr);
} // namespace mtgodot
-244
View File
@@ -1,244 +0,0 @@
#include "gr2_bridge.h"
#include <godot_cpp/classes/array_mesh.hpp>
#include <godot_cpp/classes/mesh.hpp>
#include <godot_cpp/classes/skeleton3d.hpp>
#include <godot_cpp/classes/skin.hpp>
#include <godot_cpp/core/math.hpp>
#include <godot_cpp/core/memory.hpp>
#include <godot_cpp/variant/packed_float32_array.hpp>
#include <godot_cpp/variant/packed_int32_array.hpp>
#include <godot_cpp/variant/packed_vector2_array.hpp>
#include <godot_cpp/variant/packed_vector3_array.hpp>
#include <godot_cpp/variant/utility_functions.hpp>
#include <vector>
using namespace godot;
namespace mtgodot {
gr2::Mat4 mul4x3(const gr2::Mat4 &A, const gr2::Mat4 &B) {
gr2::Mat4 R{};
for (int i = 0; i < 3; ++i) {
for (int k = 0; k < 3; ++k)
R[i * 4 + k] = A[i * 4 + 0] * B[0 * 4 + k] + A[i * 4 + 1] * B[1 * 4 + k] +
A[i * 4 + 2] * B[2 * 4 + k];
}
for (int k = 0; k < 3; ++k)
R[12 + k] = A[12] * B[k] + A[13] * B[4 + k] + A[14] * B[8 + k] + B[12 + k];
R[15] = 1.0f;
return R;
}
Transform3D gr2_to_godot(const gr2::Mat4 &m) {
// gr2 row-major, row-vector: basis columns are (m0,m1,m2),(m4,m5,m6),(m8,m9,m10);
// translation is the 4th row (m12,m13,m14).
Basis b(
Vector3(m[0], m[1], m[2]),
Vector3(m[4], m[5], m[6]),
Vector3(m[8], m[9], m[10]));
return Transform3D(b, Vector3(m[12], m[13], m[14]));
}
Transform3D make_conv(float unit_scale, bool flip_z) {
// Z-up -> Y-up: rotate -90 deg about X. Then uniform scale. Optional Z flip.
Basis b;
b = b.rotated(Vector3(1, 0, 0), Math::deg_to_rad(-90.0));
b = b.scaled(Vector3(unit_scale, unit_scale, flip_z ? -unit_scale : unit_scale));
return Transform3D(b, Vector3());
}
Skeleton3D *build_skeleton(const gr2::Skeleton &sk) {
if (sk.bones.empty()) {
return nullptr;
}
Skeleton3D *skel = memnew(Skeleton3D);
skel->set_name("Skeleton3D");
const Transform3D ip = gr2_to_godot(sk.initial_placement);
for (size_t i = 0; i < sk.bones.size(); ++i) {
skel->add_bone(String(sk.bones[i].name.c_str()));
}
for (size_t i = 0; i < sk.bones.size(); ++i) {
const gr2::Bone &bn = sk.bones[i];
skel->set_bone_parent((int)i, bn.parent);
Transform3D rest = gr2_to_godot(bn.local_transform);
if (bn.parent < 0) {
rest = ip * rest; // fold initial_placement into root bones
}
skel->set_bone_rest((int)i, rest);
skel->reset_bone_pose((int)i);
}
return skel;
}
Ref<Skin> build_skin(const gr2::Skeleton &sk) {
Ref<Skin> skin;
skin.instantiate();
for (size_t i = 0; i < sk.bones.size(); ++i) {
skin->add_bind((int)i, gr2_to_godot(sk.bones[i].inverse_world));
skin->set_bind_name((int)i, StringName(sk.bones[i].name.c_str()));
}
return skin;
}
std::vector<RenderPart> build_parts(const gr2::FileInfo &fi) {
std::vector<RenderPart> parts;
for (int mi = 0; mi < (int)fi.meshes.size(); ++mi) {
const gr2::Mesh &m = fi.meshes[mi];
if (m.vertices.empty() || m.indices.empty()) {
continue;
}
const uint32_t total = (uint32_t)m.indices.size();
if (m.tri_groups.size() <= 1) {
RenderPart p;
p.mesh = mi;
p.mat_index = m.tri_groups.empty() ? -1 : m.tri_groups[0].material_index;
p.idx_first = 0;
p.idx_count = total;
parts.push_back(p);
continue;
}
for (int g = 0; g < (int)m.tri_groups.size(); ++g) {
const gr2::TriGroup &tg = m.tri_groups[g];
if (tg.tri_count <= 0) {
continue;
}
uint32_t first = (uint32_t)(tg.tri_first < 0 ? 0 : tg.tri_first) * 3u;
uint32_t count = (uint32_t)tg.tri_count * 3u;
if (first >= total) {
continue;
}
if (first + count > total) {
count = total - first;
}
RenderPart p;
p.mesh = mi;
p.group = g;
p.mat_index = tg.material_index;
p.idx_first = first;
p.idx_count = count;
parts.push_back(p);
}
}
return parts;
}
Ref<ArrayMesh> build_mesh(const gr2::FileInfo &fi, const std::vector<RenderPart> &parts,
bool flip_winding, AABB &out_bounds) {
Ref<ArrayMesh> am;
am.instantiate();
am->set_name("ArrayMesh");
bool have_bounds = false;
// vertex arrays are per gr2 mesh; multiple parts of one mesh reuse them.
for (const RenderPart &part : parts) {
const gr2::Mesh &m = fi.meshes[part.mesh];
const int vcount = (int)m.vertices.size();
PackedVector3Array pos;
PackedVector3Array nrm;
PackedVector2Array uv;
PackedInt32Array bones;
PackedFloat32Array weights;
pos.resize(vcount);
nrm.resize(vcount);
uv.resize(vcount);
bones.resize(vcount * 4);
weights.resize(vcount * 4);
Vector3 *pos_w = pos.ptrw();
Vector3 *nrm_w = nrm.ptrw();
Vector2 *uv_w = uv.ptrw();
int32_t *bn_w = bones.ptrw();
float *wt_w = weights.ptrw();
auto slot_to_bone = [&](int slot) -> int {
if (slot < 0 || slot >= (int)m.bone_bindings.size()) {
return 0;
}
int b = m.bone_bindings[slot];
return (b < 0) ? 0 : b;
};
const int rigid_bone = m.rigid ? slot_to_bone(m.bone_bindings.empty() ? -1 : 0) : 0;
for (int i = 0; i < vcount; ++i) {
const gr2::Vertex &v = m.vertices[i];
pos_w[i] = Vector3(v.pos[0], v.pos[1], v.pos[2]);
nrm_w[i] = Vector3(v.normal[0], v.normal[1], v.normal[2]);
uv_w[i] = Vector2(v.uv0[0], v.uv0[1]);
if (!have_bounds) {
out_bounds.position = pos_w[i];
out_bounds.size = Vector3();
have_bounds = true;
} else {
out_bounds = out_bounds.expand(pos_w[i]);
}
if (m.rigid) {
bn_w[i * 4 + 0] = rigid_bone;
bn_w[i * 4 + 1] = bn_w[i * 4 + 2] = bn_w[i * 4 + 3] = 0;
wt_w[i * 4 + 0] = 1.0f;
wt_w[i * 4 + 1] = wt_w[i * 4 + 2] = wt_w[i * 4 + 3] = 0.0f;
continue;
}
float wsum = 0.0f;
for (int k = 0; k < 4; ++k) {
wsum += v.bone_weight[k];
}
for (int k = 0; k < 4; ++k) {
bn_w[i * 4 + k] = slot_to_bone(v.bone_index[k]);
wt_w[i * 4 + k] = (wsum > 0.0f) ? (v.bone_weight[k] / (float)wsum) : (k == 0 ? 1.0f : 0.0f);
}
}
// indices: this part's sub-range of mesh.indices only
const uint32_t ib = part.idx_first;
const uint32_t ic = (part.idx_count && part.idx_first + part.idx_count <= m.indices.size())
? part.idx_count
: (uint32_t)m.indices.size() - ib;
PackedInt32Array idx;
idx.resize((int)ic);
int32_t *idx_w = idx.ptrw();
if (flip_winding) {
for (uint32_t t = 0; t + 2 < ic; t += 3) {
idx_w[t + 0] = (int)m.indices[ib + t + 0];
idx_w[t + 1] = (int)m.indices[ib + t + 2];
idx_w[t + 2] = (int)m.indices[ib + t + 1];
}
} else {
for (uint32_t t = 0; t < ic; ++t) {
idx_w[t] = (int)m.indices[ib + t];
}
}
Array arrays;
arrays.resize(Mesh::ARRAY_MAX);
arrays[Mesh::ARRAY_VERTEX] = pos;
arrays[Mesh::ARRAY_NORMAL] = nrm;
arrays[Mesh::ARRAY_TEX_UV] = uv;
arrays[Mesh::ARRAY_BONES] = bones;
arrays[Mesh::ARRAY_WEIGHTS] = weights;
arrays[Mesh::ARRAY_INDEX] = idx;
am->add_surface_from_arrays(Mesh::PRIMITIVE_TRIANGLES, arrays);
String nm = m.name.empty() ? String("surf_") + itos(part.mesh) : String(m.name.c_str());
if (part.group >= 0) {
nm += String("#") + itos(part.group);
}
am->surface_set_name(am->get_surface_count() - 1, nm);
}
if (!have_bounds) {
out_bounds = AABB();
}
return am;
}
} // namespace mtgodot
-65
View File
@@ -1,65 +0,0 @@
#pragma once
// gr2_bridge — libgr2 POD views -> Godot scene objects.
//
// Coordinate handling (the one place it lives; see docs/GODOT-POC-PLAN.md §02):
// - gr2 Mat4 is row-major, row-vector math, translation in elements 12..14.
// - A plain 4x4 transpose maps it to a Godot Transform3D (column-vector,
// translation in .origin). gr2_to_godot() does exactly that.
// - Z-up(cm) -> Y-up(m) and optional handedness flip are NOT applied here;
// they go on the Metin2Model node transform (make_conv()).
#include <godot_cpp/classes/ref.hpp>
#include <godot_cpp/variant/aabb.hpp>
#include <godot_cpp/variant/transform3d.hpp>
#include <gr2/gr2.h>
#include <cstdint>
#include <vector>
namespace godot {
class Skeleton3D;
class Skin;
class ArrayMesh;
} // namespace godot
namespace mtgodot {
// 4x4 transpose: gr2 row-major/row-vector -> Godot Transform3D.
godot::Transform3D gr2_to_godot(const gr2::Mat4 &m);
// gr2 affine compose (row-vector: result applies A then B), same as libgr2's
// internal mul4x3. R = A · B with the 4th row treated as translation.
gr2::Mat4 mul4x3(const gr2::Mat4 &A, const gr2::Mat4 &B);
// Z-up cm -> Y-up m (+ optional Z flip for LH->RH content).
godot::Transform3D make_conv(float unit_scale, bool flip_z);
// Build a Skeleton3D (rest pose from bone local transforms; initial_placement
// folded into root bones). Returns nullptr if the skeleton has no bones.
godot::Skeleton3D *build_skeleton(const gr2::Skeleton &sk);
// One render part = one Godot surface. A gr2 mesh with N>1 tri_groups splits into
// N parts (each its own material); a mesh with 0/1 groups is one whole-mesh part.
struct RenderPart {
int mesh = -1; // gr2::FileInfo::meshes index
int group = -1; // gr2::Mesh::tri_groups index, or -1 = whole mesh
int mat_index = -1; // tri_groups[group].material_index (mesh-local), or -1
uint32_t idx_first = 0; // start into mesh.indices (= tri_first * 3)
uint32_t idx_count = 0; // length into mesh.indices (= tri_count * 3)
};
std::vector<RenderPart> build_parts(const gr2::FileInfo &fi);
// Skin whose bind list is parallel to the skeleton bones:
// bind i -> bone i, pose = gr2_to_godot(bone[i].inverse_world)
godot::Ref<godot::Skin> build_skin(const gr2::Skeleton &sk);
// One ArrayMesh with a surface per RenderPart (see build_parts).
// ARRAY_BONES values are skeleton bone indices (mesh slot -> bone via
// mesh.bone_bindings). Rigid meshes are bound rigidly to bone_bindings[0].
// Fills out_bounds with the untransformed vertex AABB.
godot::Ref<godot::ArrayMesh> build_mesh(const gr2::FileInfo &fi,
const std::vector<RenderPart> &parts, bool flip_winding,
godot::AABB &out_bounds);
} // namespace mtgodot
-276
View File
@@ -1,276 +0,0 @@
#include "m2_material.h"
#include <godot_cpp/classes/image_texture.hpp>
#include <godot_cpp/classes/shader.hpp>
#include <godot_cpp/classes/shader_material.hpp>
#include <godot_cpp/classes/texture2d.hpp>
#include <godot_cpp/variant/color.hpp>
using namespace godot;
namespace mtgodot {
namespace {
// blend_mix shader: covers opaque (mode 0, alpha-scissor) and alpha (mode 1).
const char *SRC_MIX = R"(shader_type spatial;
render_mode blend_mix, depth_draw_opaque, cull_back, diffuse_lambert, specular_disabled;
uniform sampler2D albedo_tex : source_color, filter_linear_mipmap, repeat_enable;
uniform bool use_texture = true;
uniform vec4 modulate : source_color = vec4(1.0);
uniform float alpha_scissor : hint_range(0.0, 1.0) = 0.5;
uniform int mode = 0; // 0 opaque | 1 alpha-blend | 2 alpha-test (cutout)
// sphere-map specular (EterGrnLib/Material.cpp:305 __ApplySpecularRenderState)
uniform sampler2D spec_map : source_color, filter_linear_mipmap, repeat_enable;
uniform float spec_power = 0.0;
uniform bool spec_enable = false;
uniform float lod_fade = 1.0; // LOD crossfade multiplier (1 = fully shown)
void fragment() {
vec4 c = use_texture ? texture(albedo_tex, UV) : vec4(1.0);
float spec_mask = c.a; // tex.a before modulate == D3DTA_TEXTURE alpha
c *= modulate; // no vertex color: Metin2 PC meshes carry none (ARRAY_COLOR absent)
if (mode == 2 && c.a < alpha_scissor) {
discard;
}
ALBEDO = c.rgb;
ALPHA = ((mode == 1) ? c.a : 1.0) * lod_fade;
// Opaque only: client early-outs to plain diffuse when D3DRS_ALPHABLENDENABLE.
// stage1 COLOROP MODULATEALPHA_ADDCOLOR = CURRENT.rgb + CURRENT.a*sphere.rgb,
// CURRENT.a = tex.a * D3DRS_TEXTUREFACTOR.a (= spec_power). texcoord =
// D3DTSS_TCI_CAMERASPACEREFLECTIONVECTOR -> view-space reflect(); .xy as UV.
if (spec_enable && spec_power > 0.0 && mode == 0) {
// Sphere-map metallic sheen (EterGrnLib/Material.cpp:305). The client masks
// this with the armor texture's alpha (metal=1, cloth=0); our loose-file
// texture resolution can't be trusted for that alpha, so bias it to
// grazing angles (Fresnel) and knock the level down — reads as an edge
// sheen instead of a full-body chrome mirror.
vec3 vdir = normalize(VERTEX);
vec3 ndir = normalize(NORMAL);
float fres = pow(clamp(1.0 - abs(dot(ndir, vdir)), 0.0, 1.0), 3.0);
vec3 refl = reflect(vdir, ndir);
EMISSION = texture(spec_map, refl.xy * 0.5 + 0.5).rgb
* (spec_mask * spec_power * fres * 0.5);
}
}
)";
// Skinned variant of SRC_MIX: LBS in vertex() with FULL per-bone matrices from a
// float texture (no Skeleton3D). bones_tex is RGBAF, 3 x bone_count; row = bone,
// texel j = column j of the row-vector skin matrix (skinned = [pos 1] * M).
const char *SRC_SKIN = R"(shader_type spatial;
render_mode blend_mix, depth_draw_opaque, cull_back, diffuse_lambert, specular_disabled;
uniform sampler2D albedo_tex : source_color, filter_linear_mipmap, repeat_enable;
uniform bool use_texture = true;
uniform vec4 modulate : source_color = vec4(1.0);
uniform float alpha_scissor : hint_range(0.0, 1.0) = 0.5;
uniform int mode = 0;
uniform sampler2D bones_tex : filter_nearest; // RGBAF, 3 x bone_count
uniform sampler2D spec_map : source_color, filter_linear_mipmap, repeat_enable;
uniform float spec_power = 0.0;
uniform bool spec_enable = false;
uniform float lod_fade = 1.0; // LOD crossfade multiplier (1 = fully shown)
void vertex() {
vec4 p = vec4(VERTEX, 1.0);
vec3 sp = vec3(0.0);
vec3 sn = vec3(0.0);
ivec4 bi = ivec4(BONE_INDICES);
vec4 bw = BONE_WEIGHTS;
float wsum = bw.x + bw.y + bw.z + bw.w;
if (wsum <= 0.0) { bw = vec4(1.0, 0.0, 0.0, 0.0); wsum = 1.0; }
for (int k = 0; k < 4; k++) {
float w = bw[k] / wsum;
if (w <= 0.0) { continue; }
int r = bi[k];
vec4 c0 = texelFetch(bones_tex, ivec2(0, r), 0);
vec4 c1 = texelFetch(bones_tex, ivec2(1, r), 0);
vec4 c2 = texelFetch(bones_tex, ivec2(2, r), 0);
sp += w * vec3(dot(p, c0), dot(p, c1), dot(p, c2));
sn += w * vec3(dot(NORMAL, c0.xyz), dot(NORMAL, c1.xyz), dot(NORMAL, c2.xyz));
}
VERTEX = sp;
NORMAL = normalize(sn);
}
void fragment() {
vec4 c = use_texture ? texture(albedo_tex, UV) : vec4(1.0);
float spec_mask = c.a;
c *= modulate;
if (mode == 2 && c.a < alpha_scissor) { discard; }
ALBEDO = c.rgb;
ALPHA = ((mode == 1) ? c.a : 1.0) * lod_fade;
if (spec_enable && spec_power > 0.0 && mode == 0) {
// Sphere-map metallic sheen (EterGrnLib/Material.cpp:305). The client masks
// this with the armor texture's alpha (metal=1, cloth=0); our loose-file
// texture resolution can't be trusted for that alpha, so bias it to
// grazing angles (Fresnel) and knock the level down — reads as an edge
// sheen instead of a full-body chrome mirror.
vec3 vdir = normalize(VERTEX);
vec3 ndir = normalize(NORMAL);
float fres = pow(clamp(1.0 - abs(dot(ndir, vdir)), 0.0, 1.0), 3.0);
vec3 refl = reflect(vdir, ndir);
EMISSION = texture(spec_map, refl.xy * 0.5 + 0.5).rgb
* (spec_mask * spec_power * fres * 0.5);
}
}
)";
// blend_add shader: additive glow (effects, enchant overlays).
const char *SRC_ADD = R"(shader_type spatial;
render_mode blend_add, depth_draw_opaque, depth_test_disabled, cull_back, unshaded;
uniform sampler2D albedo_tex : source_color, filter_linear_mipmap, repeat_enable;
uniform bool use_texture = true;
uniform vec4 modulate : source_color = vec4(1.0);
void fragment() {
vec4 c = use_texture ? texture(albedo_tex, UV) : vec4(1.0);
c *= modulate; // no vertex color: Metin2 PC meshes carry none (ARRAY_COLOR absent)
ALBEDO = c.rgb * c.a;
ALPHA = 1.0;
}
)";
// Additive surfaces need the same vertex deformation as opaque/alpha surfaces.
// Keeping this as a separate shader preserves blend_add/unshaded render modes.
const char *SRC_ADD_SKIN = R"(shader_type spatial;
render_mode blend_add, depth_draw_opaque, depth_test_disabled, cull_back, unshaded;
uniform sampler2D albedo_tex : source_color, filter_linear_mipmap, repeat_enable;
uniform bool use_texture = true;
uniform vec4 modulate : source_color = vec4(1.0);
uniform sampler2D bones_tex : filter_nearest;
void vertex() {
vec4 p = vec4(VERTEX, 1.0);
vec3 sp = vec3(0.0);
vec3 sn = vec3(0.0);
ivec4 bi = ivec4(BONE_INDICES);
vec4 bw = BONE_WEIGHTS;
float wsum = bw.x + bw.y + bw.z + bw.w;
if (wsum <= 0.0) { bw = vec4(1.0, 0.0, 0.0, 0.0); wsum = 1.0; }
for (int k = 0; k < 4; k++) {
float w = bw[k] / wsum;
if (w <= 0.0) { continue; }
int r = bi[k];
vec4 c0 = texelFetch(bones_tex, ivec2(0, r), 0);
vec4 c1 = texelFetch(bones_tex, ivec2(1, r), 0);
vec4 c2 = texelFetch(bones_tex, ivec2(2, r), 0);
sp += w * vec3(dot(p, c0), dot(p, c1), dot(p, c2));
sn += w * vec3(dot(NORMAL, c0.xyz), dot(NORMAL, c1.xyz), dot(NORMAL, c2.xyz));
}
VERTEX = sp;
NORMAL = normalize(sn);
}
void fragment() {
vec4 c = use_texture ? texture(albedo_tex, UV) : vec4(1.0);
c *= modulate;
ALBEDO = c.rgb * c.a;
ALPHA = 1.0;
}
)";
Ref<Shader> s_mix;
Ref<Shader> s_add;
Ref<Shader> s_skin;
Ref<Shader> s_add_skin;
// cull_disabled variants for two-sided parts (hair / cloth / foliage; client
// ExtendedData "Two-sided" -> D3DRS_CULLMODE = D3DCULL_NONE).
Ref<Shader> s_mix_2s;
Ref<Shader> s_add_2s;
Ref<Shader> s_skin_2s;
Ref<Shader> s_add_skin_2s;
Ref<Shader> s_opaque[4];
Ref<Shader> shader_for(bool additive, bool skinned, bool two_sided, bool opaque) {
if (opaque) {
Ref<Shader> &solid = s_opaque[(skinned ? 2 : 0) + (two_sided ? 1 : 0)];
if (solid.is_null()) {
String code = skinned ? SRC_SKIN : SRC_MIX;
// Writing ALPHA puts even alpha=1 into Godot's transparent pass.
// Solid faces must write depth before the hair cutout is drawn.
code = code.replace("ALPHA = ((mode == 1) ? c.a : 1.0) * lod_fade;",
"if (lod_fade < fract(sin(dot(FRAGCOORD.xy, vec2(12.9898, 78.233))) * 43758.5453)) { discard; }");
if (two_sided)
code = code.replace("cull_back", "cull_disabled");
solid.instantiate();
solid->set_code(code);
}
return solid;
}
Ref<Shader> &slot = two_sided
? (additive ? (skinned ? s_add_skin_2s : s_add_2s) : (skinned ? s_skin_2s : s_mix_2s))
: (additive ? (skinned ? s_add_skin : s_add) : (skinned ? s_skin : s_mix));
if (slot.is_null()) {
slot.instantiate();
const char *base =
additive ? (skinned ? SRC_ADD_SKIN : SRC_ADD) : (skinned ? SRC_SKIN : SRC_MIX);
slot->set_code(two_sided ? String(base).replace("cull_back", "cull_disabled")
: String(base));
}
return slot;
}
} // namespace
Ref<ShaderMaterial> make_material(const MaterialDesc &d) {
Ref<ShaderMaterial> m;
m.instantiate();
const bool additive = (d.blend == BlendMode::Add);
const bool skinned = d.skinned;
m->set_shader(shader_for(additive, skinned, d.two_sided,
d.blend == BlendMode::Opaque || d.blend == BlendMode::AlphaTest));
if (skinned && d.bones_tex.is_valid()) {
m->set_shader_parameter("bones_tex", d.bones_tex);
}
const bool has_tex = d.albedo.is_valid();
m->set_shader_parameter("use_texture", has_tex);
if (has_tex) {
m->set_shader_parameter("albedo_tex", d.albedo);
}
m->set_shader_parameter("modulate", has_tex ? Color(1, 1, 1, 1) : Color(0.8, 0.8, 0.82, 1));
if (!additive) {
// sphere-map specular (dormant unless spec_power > 0 and a map is bound)
const bool spec = d.spec_power > 0.0f && d.spec_map.is_valid();
m->set_shader_parameter("spec_enable", spec);
m->set_shader_parameter("spec_power", d.spec_power);
if (spec) {
m->set_shader_parameter("spec_map", d.spec_map);
}
m->set_shader_parameter("alpha_scissor", d.alpha_scissor);
int mode = 0;
if (d.blend == BlendMode::Alpha) {
mode = 1;
} else if (d.blend == BlendMode::AlphaTest) {
mode = 2;
}
m->set_shader_parameter("mode", mode);
}
if (d.blend == BlendMode::Alpha) {
m->set_render_priority(1);
} else if (additive) {
m->set_render_priority(2);
}
return m;
}
void cleanup_material_shaders() {
for (auto &shader : s_opaque)
shader.unref();
s_mix.unref();
s_add.unref();
s_skin.unref();
s_add_skin.unref();
s_mix_2s.unref();
s_add_2s.unref();
s_skin_2s.unref();
s_add_skin_2s.unref();
}
} // namespace mtgodot
-59
View File
@@ -1,59 +0,0 @@
#pragma once
// m2_material — Metin2-style forward material as a Godot ShaderMaterial.
//
// M2.5 infrastructure. The gr2 fixed-function texture-stage / blend data is not
// yet exposed by libgr2's POD API (see docs/GODOT-POC-PLAN.md §M2.5 gap list),
// so callers pick the blend mode heuristically for now. The shader itself
// already covers the common Metin2 cases: modulate(tex, vertex_color) + light,
// alpha-test, alpha-blend, additive.
#include <godot_cpp/classes/ref.hpp>
namespace godot {
class ShaderMaterial;
class Texture2D;
class ImageTexture;
} // namespace godot
namespace mtgodot {
enum class BlendMode {
Opaque, // blend_mix, ALPHA=1, no discard
Alpha, // blend_mix, sorted, uses texture alpha
AlphaTest, // blend_mix, discard below alpha_scissor (cutout)
Add, // blend_add (glow / effects)
};
struct MaterialDesc {
godot::Ref<godot::Texture2D> albedo; // null -> flat white
BlendMode blend = BlendMode::Opaque;
float alpha_scissor = 0.5f;
bool two_sided = true; // winding not yet verified per-model
// GPU skinning: LBS with FULL per-bone affine matrices in the vertex shader
// (no Skeleton3D -> no quaternion orthonormalization -> shear preserved).
// `bones_tex` is RGBAF, size 3 x bone_count; columns 0..2 = the 3 columns of
// the row-vector skin matrix (see metin2_model.cpp::gpu_skin).
bool skinned = false;
godot::Ref<godot::ImageTexture> bones_tex;
// Sphere-map specular — port of CGrannyMaterial::__ApplySpecularRenderState
// (EterGrnLib/Material.cpp:305). Opaque armor gets a metallic highlight from a
// camera-space reflection-vector lookup into a shared sphere map, added on top
// of the lit diffuse:
// out.rgb = tex.rgb*modulate + (tex.a * spec_power) * sphere(reflect_uv)
// Client enables this per skin-part only when the equipped body-armor item's
// item_proto `bSpecular > 0` (power = bSpecular/100); the base body is flat.
// Dormant here until an equipment layer feeds a power: spec_power <= 0 ->
// the shader branch is skipped and output is byte-identical to before.
godot::Ref<godot::Texture2D> spec_map; // shared sphere map; null -> disabled
float spec_power = 0.0f; // fSpecularPower (D3DRS_TEXTUREFACTOR.a); 0 = off
};
godot::Ref<godot::ShaderMaterial> make_material(const MaterialDesc &d);
// Drop the extension-owned shader cache before GDExtension teardown. Materials
// still alive in the scene keep their own Ref, so this only removes the static
// lifetime that otherwise survives Godot's leak check.
void cleanup_material_shaders();
} // namespace mtgodot
-856
View File
@@ -1,856 +0,0 @@
#include "metin2_anim.h"
#include "asset_io.h"
#include "gr2_bridge.h"
#include "metin2_model.h"
#include <godot_cpp/classes/skeleton3d.hpp>
#include <godot_cpp/core/class_db.hpp>
#include <godot_cpp/variant/utility_functions.hpp>
#include <cmath>
#include <string>
#include <list>
#include <godot_cpp/classes/mesh_instance3d.hpp>
#include <godot_cpp/classes/os.hpp>
#include <godot_cpp/classes/skin.hpp>
#include <godot_cpp/classes/file_access.hpp>
#include <godot_cpp/variant/array.hpp>
#include <godot_cpp/variant/dictionary.hpp>
#include <godot_cpp/variant/packed_int32_array.hpp>
#include <combo_table.h>
using namespace godot;
namespace mtgodot {
namespace {
struct ClipEntry {
String path;
uint64_t modified;
size_t bytes;
std::shared_ptr<const gr2::File> file;
};
// Immutable decoded clips are shared, not playback time/pose/event cursors.
// Main-thread animation loading only; no Godot objects survive in this cache.
std::list<ClipEntry> clip_cache;
size_t clip_bytes = 0;
uint64_t clip_hits = 0, clip_misses = 0;
constexpr size_t CLIP_LIMIT = 32, CLIP_BYTE_LIMIT = 64 * 1024 * 1024;
struct MsaEntry {
String path;
uint64_t modified;
fmt::Msa metadata;
};
std::list<MsaEntry> msa_cache;
uint64_t msa_hits = 0, msa_misses = 0;
// Keep events immutable in the cache; reload copies them into each actor.
bool cached_msa(const String &path, fmt::Msa &metadata, std::string *error) {
const uint64_t modified = FileAccess::get_modified_time(path);
for (auto it = msa_cache.begin(); it != msa_cache.end(); ++it) {
if (it->path != path) continue;
if (it->modified == modified) {
++msa_hits;
metadata = it->metadata;
msa_cache.splice(msa_cache.begin(), msa_cache, it);
return true;
}
msa_cache.erase(it);
break;
}
++msa_misses;
if (!fmt::parse_msa_file(std::string(path.utf8().get_data()), metadata, error)) return false;
if (msa_cache.size() >= 128) msa_cache.pop_back();
msa_cache.push_front({path, modified, metadata});
return true;
}
std::shared_ptr<const gr2::File> cached_clip(const String &path, gr2::LoadError *err) {
uint64_t modified = FileAccess::get_modified_time(path);
for (auto it = clip_cache.begin(); it != clip_cache.end(); ++it) {
if (it->path != path) continue;
if (it->modified == modified) {
++clip_hits;
clip_cache.splice(clip_cache.begin(), clip_cache, it);
return clip_cache.front().file;
}
clip_bytes -= it->bytes;
clip_cache.erase(it);
break;
}
++clip_misses;
auto loaded = gr2_from_file(path, err);
if (!loaded) return {};
size_t bytes = 0;
for (size_t i = 0; i < loaded->sections().size(); ++i) bytes += loaded->section_bytes(i).size();
auto file = std::make_shared<const gr2::File>(std::move(*loaded));
if (bytes <= CLIP_BYTE_LIMIT) {
while (!clip_cache.empty() && (clip_cache.size() >= CLIP_LIMIT || clip_bytes + bytes > CLIP_BYTE_LIMIT)) {
clip_bytes -= clip_cache.back().bytes;
clip_cache.pop_back();
}
clip_cache.push_front({path, modified, bytes, file});
clip_bytes += bytes;
}
return file;
}
}
Dictionary Metin2AnimPlayer::get_clip_cache_stats() {
Dictionary d;
d["hits"] = clip_hits;
d["misses"] = clip_misses;
d["entries"] = static_cast<int64_t>(clip_cache.size());
d["section_bytes"] = static_cast<int64_t>(clip_bytes);
d["msa_hits"] = msa_hits;
d["msa_misses"] = msa_misses;
d["msa_entries"] = static_cast<int64_t>(msa_cache.size());
return d;
}
void Metin2AnimPlayer::clear_clip_cache() {
clip_cache.clear();
clip_bytes = 0;
clip_hits = clip_misses = 0;
msa_cache.clear();
msa_hits = msa_misses = 0;
}
Metin2AnimPlayer::Metin2AnimPlayer() {}
Metin2AnimPlayer::~Metin2AnimPlayer() = default;
void Metin2AnimPlayer::_bind_methods() {
ClassDB::bind_static_method("Metin2AnimPlayer", D_METHOD("get_clip_cache_stats"), &Metin2AnimPlayer::get_clip_cache_stats);
ClassDB::bind_static_method("Metin2AnimPlayer", D_METHOD("clear_clip_cache"), &Metin2AnimPlayer::clear_clip_cache);
ClassDB::bind_method(D_METHOD("set_anim_path", "path"), &Metin2AnimPlayer::set_anim_path);
ClassDB::bind_method(D_METHOD("get_anim_path"), &Metin2AnimPlayer::get_anim_path);
ClassDB::bind_method(D_METHOD("set_model_path", "path"), &Metin2AnimPlayer::set_model_path);
ClassDB::bind_method(D_METHOD("get_model_path"), &Metin2AnimPlayer::get_model_path);
ClassDB::bind_method(D_METHOD("set_playing", "v"), &Metin2AnimPlayer::set_playing);
ClassDB::bind_method(D_METHOD("get_playing"), &Metin2AnimPlayer::get_playing);
ClassDB::bind_method(D_METHOD("set_loop", "v"), &Metin2AnimPlayer::set_loop);
ClassDB::bind_method(D_METHOD("get_loop"), &Metin2AnimPlayer::get_loop);
ClassDB::bind_method(D_METHOD("set_time_scale", "s"), &Metin2AnimPlayer::set_time_scale);
ClassDB::bind_method(D_METHOD("get_time_scale"), &Metin2AnimPlayer::get_time_scale);
ClassDB::bind_method(D_METHOD("set_motion_loop_count", "count"), &Metin2AnimPlayer::set_motion_loop_count);
ClassDB::bind_method(D_METHOD("get_motion_loop_count"), &Metin2AnimPlayer::get_motion_loop_count);
ClassDB::bind_method(D_METHOD("set_blend_time", "s"), &Metin2AnimPlayer::set_blend_time);
ClassDB::bind_method(D_METHOD("get_blend_time"), &Metin2AnimPlayer::get_blend_time);
ClassDB::bind_method(D_METHOD("set_time", "t"), &Metin2AnimPlayer::set_time);
ClassDB::bind_method(D_METHOD("get_time"), &Metin2AnimPlayer::get_time);
ClassDB::bind_method(D_METHOD("get_duration"), &Metin2AnimPlayer::get_duration);
ClassDB::bind_method(D_METHOD("reload"), &Metin2AnimPlayer::reload);
ClassDB::bind_method(D_METHOD("get_info"), &Metin2AnimPlayer::get_info);
ClassDB::bind_method(D_METHOD("selfcheck", "samples"), &Metin2AnimPlayer::selfcheck, DEFVAL(24));
ClassDB::bind_method(D_METHOD("get_accumulation"), &Metin2AnimPlayer::get_accumulation);
ClassDB::bind_method(D_METHOD("get_events"), &Metin2AnimPlayer::get_events);
ClassDB::bind_method(D_METHOD("get_effect_bone_pose", "bone"), &Metin2AnimPlayer::get_effect_bone_pose);
ClassDB::bind_method(D_METHOD("get_loop_data"), &Metin2AnimPlayer::get_loop_data);
ClassDB::bind_method(D_METHOD("get_motion_data"), &Metin2AnimPlayer::get_motion_data);
ClassDB::bind_static_method("Metin2AnimPlayer",
D_METHOD("parse_combo_tables", "py_path"), &Metin2AnimPlayer::parse_combo_tables);
ClassDB::bind_static_method("Metin2AnimPlayer",
D_METHOD("make_combo_key", "motion_mode", "combo_type"), &Metin2AnimPlayer::make_combo_key);
// Fired when playback time crosses a .msa MotionEventData entry.
ADD_SIGNAL(MethodInfo("motion_event",
PropertyInfo(Variant::INT, "type"),
PropertyInfo(Variant::STRING, "effect"),
PropertyInfo(Variant::STRING, "sound"),
PropertyInfo(Variant::VECTOR3, "pos")));
ADD_SIGNAL(MethodInfo("playback_finished"));
ADD_SIGNAL(MethodInfo("motion_event_detailed", PropertyInfo(Variant::DICTIONARY, "event")));
ADD_PROPERTY(PropertyInfo(Variant::STRING, "anim_path", PROPERTY_HINT_GLOBAL_FILE, "*.gr2,*.msa"),
"set_anim_path", "get_anim_path");
ADD_PROPERTY(PropertyInfo(Variant::NODE_PATH, "model_path", PROPERTY_HINT_NODE_PATH_VALID_TYPES, "Node3D"),
"set_model_path", "get_model_path");
ADD_PROPERTY(PropertyInfo(Variant::BOOL, "playing"), "set_playing", "get_playing");
ADD_PROPERTY(PropertyInfo(Variant::BOOL, "loop"), "set_loop", "get_loop");
ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "time_scale", PROPERTY_HINT_RANGE, "0,4,0.01"),
"set_time_scale", "get_time_scale");
ADD_PROPERTY(PropertyInfo(Variant::INT, "motion_loop_count"),
"set_motion_loop_count", "get_motion_loop_count");
ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "blend_time", PROPERTY_HINT_RANGE, "0,1,0.01"),
"set_blend_time", "get_blend_time");
}
void Metin2AnimPlayer::set_anim_path(const String &p) {
if (p == anim_path) {
if (!playing) {
time = 0.0;
prev_time = 0.0;
playing = true;
}
return;
}
// SetMotionLoopCount belongs to the newly bound motion. Do not leak a
// previous skill's override into a normal walk/attack/action clip.
motion_loop_override = false;
motion_loop_count = 0;
// Start a crossfade from the clip that is currently playing.
if (is_inside_tree() && anim_file && blend_time > 0.0) {
prev_anim_file = std::move(anim_file); // anim_file now empty; reload() refills it
prev_anim_duration = duration;
prev_anim_loop = loop;
double pt = time;
if (prev_anim_duration > 0.0) {
if (prev_anim_loop) {
pt = std::fmod(pt, prev_anim_duration);
if (pt < 0.0) {
pt += prev_anim_duration;
}
} else {
pt = std::fmax(0.0, std::fmin(pt, prev_anim_duration));
}
}
prev_anim_time = pt;
blend_elapsed = 0.0;
}
anim_path = p;
if (is_inside_tree()) {
reload();
}
}
void Metin2AnimPlayer::set_motion_loop_count(int count) {
if (count > 0) {
motion_loop_override = true;
motion_loop_count = count;
} else {
motion_loop_override = false;
motion_loop_count = msa_metadata.has_loop_data ? msa_metadata.motion_loop_count : 0;
}
}
void Metin2AnimPlayer::set_model_path(const NodePath &p) {
model_path = p;
}
void Metin2AnimPlayer::set_time(double t) {
time = (!loop && duration > 0.0) ? std::fmax(0.0, std::fmin(t, duration)) : t;
prev_time = time; // scrubbing must not replay every event since the old cursor
if (is_inside_tree()) {
apply_pose(time);
}
}
Metin2Model *Metin2AnimPlayer::resolve_model() const {
if (model_path.is_empty()) {
return nullptr;
}
Node *n = get_node_or_null(model_path);
return Object::cast_to<Metin2Model>(n);
}
// "d:\Ymir Work\pc\warrior\action\dance.gr2" -> "<root>/PC/ymir work/pc/warrior/action/dance.gr2"
// where <root> is found by walking up from the .msa's own dir past "ymir work".
// Falls back to <msa_dir>/<basename> (the anim gr2 is usually right next to it).
static String strip_dpath(const String &raw) {
String p = raw.replace("\\", "/");
int k = p.to_lower().find("ymir work");
return k >= 0 ? p.substr(k) : p; // "ymir work/pc/.../x.gr2"
}
String Metin2AnimPlayer::resolve_anim_gr2(const String &spec, const fmt::Msa &m) {
String low = spec.to_lower();
if (!low.ends_with(".msa")) {
return spec; // already a .gr2 path
}
const String motion = String(m.motion_gr2.c_str());
const String base = motion.replace("\\", "/").get_file();
const String msa_dir = spec.get_base_dir();
// 1. sibling of the .msa
String cand = msa_dir.path_join(base);
if (FileAccess::file_exists(cand)) {
return cand;
}
// 2. <root>/PC/<ymir-work tail>, root = ancestor of the .msa above "ymir work"
String tail = strip_dpath(motion); // "ymir work/pc/.../x.gr2"
int ky = msa_dir.to_lower().find("ymir work");
if (ky > 0) {
String root = spec.substr(0, ky); // ".../assets/PC/"
cand = root.path_join(tail.substr(String("ymir work/").length()));
if (FileAccess::file_exists(cand)) {
return cand;
}
cand = root.get_base_dir().path_join(tail); // ".../assets/" + "ymir work/..."
if (FileAccess::file_exists(cand)) {
return cand;
}
}
UtilityFunctions::push_warning(String("[Metin2AnimPlayer] .msa motion gr2 not found: ") + motion +
" (tried " + msa_dir.path_join(base) + ")");
return cand;
}
static Dictionary motion_event_dict(const fmt::MotionEvent &ev) {
Dictionary d;
d["type"] = ev.type;
d["start_time"] = ev.start_time;
d["duration_time"] = ev.duration_time;
d["effect"] = String(ev.effect_file.c_str());
d["sound"] = String(ev.sound_file.c_str());
d["pos"] = Vector3(ev.position[0], ev.position[1], ev.position[2]);
d["bone"] = String(ev.attaching_bone.c_str());
d["attaching"] = ev.attaching;
d["following"] = ev.following;
d["independent"] = ev.independent;
d["fishing_effect"] = ev.fishing_effect;
d["fly_file"] = String(ev.fly_file.c_str());
d["fly_bone"] = String(ev.fly_attaching_bone.c_str());
d["fly_attaching"] = ev.fly_attaching;
d["fly_pos"] = Vector3(ev.fly_position[0], ev.fly_position[1], ev.fly_position[2]);
d["power"] = ev.power;
d["affecting_range"] = ev.affecting_range;
d["enable_hit_process"] = ev.enable_hit_process;
d["attack_type"] = ev.attack_type;
d["hitting_type"] = ev.hitting_type;
d["stiffen_time"] = ev.stiffen_time;
d["invisible_time"] = ev.invisible_time;
d["external_force"] = ev.external_force;
d["hit_limit_count"] = ev.hit_limit_count;
d["collision_type"] = ev.collision_type;
Array spheres;
for (const fmt::MotionEvent::CollisionSphere &sphere : ev.collision_spheres) {
Dictionary sd;
sd["radius"] = sphere.radius;
sd["pos"] = Vector3(sphere.position[0], sphere.position[1], sphere.position[2]);
spheres.push_back(sd);
}
d["collision_spheres"] = spheres;
return d;
}
Dictionary Metin2AnimPlayer::get_effect_bone_pose(const String &bone) {
Dictionary result;
Metin2Model *model = resolve_model();
const gr2::Skeleton *sk = model ? model->gr2_skeleton() : nullptr;
if (!sk || !anim_file) return result;
for (size_t i = 0; i < sk->bones.size() && i < world_buf.size(); ++i) {
if (String(sk->bones[i].name.c_str()) == bone) {
result["transform"] = gr2_to_godot(world_buf[i]);
break;
}
}
return result;
}
void Metin2AnimPlayer::emit_motion_event(const fmt::MotionEvent &ev) {
emit_signal("motion_event", ev.type, String(ev.effect_file.c_str()),
String(ev.sound_file.c_str()), Vector3(ev.position[0], ev.position[1], ev.position[2]));
emit_signal("motion_event_detailed", motion_event_dict(ev));
}
Array Metin2AnimPlayer::get_events() const {
Array a;
for (const fmt::MotionEvent &ev : events) {
a.push_back(motion_event_dict(ev));
}
return a;
}
Dictionary Metin2AnimPlayer::get_loop_data() const {
Dictionary d;
d["present"] = msa_metadata.has_loop_data;
d["count"] = msa_metadata.motion_loop_count;
d["cancel_enable"] = msa_metadata.loop_cancel_enable;
d["start_time"] = msa_metadata.loop_start_time;
d["end_time"] = msa_metadata.loop_end_time;
return d;
}
// CLIENT-GAP §3.3 / §3.5 — the currently-loaded .msa's ComboInputData / AttackingData.
// `next_combo` is the reference普攻节奏 (CRaceMotionData::GetNextComboTime); when the
// motion has no ComboInputData it falls back to MotionDuration * 0.9.
Dictionary Metin2AnimPlayer::get_motion_data() const {
const fmt::Msa &m = msa_metadata;
Dictionary d;
d["duration"] = duration > 0.0 ? duration : m.duration;
d["has_combo_input"] = m.has_combo_input;
d["pre_input_time"] = m.combo_pre_input_time;
d["direct_input_time"] = m.combo_direct_input_time;
d["input_limit_time"] = m.combo_input_limit_time;
d["link_time"] = m.combo_link_time;
const double dur = duration > 0.0 ? duration : m.duration;
d["next_combo"] = m.has_combo_input && m.combo_direct_input_time > 0.0
? (double)m.combo_direct_input_time
: dur * 0.9;
d["has_attacking_data"] = m.has_attacking_data;
d["attacking_type"] = m.attacking_type;
d["motion_type"] = m.motion_type;
d["hitting_type"] = m.hitting_type;
d["attack_start_time"] = m.attack_start_time;
d["attack_end_time"] = m.attack_end_time;
d["stiffen_time"] = m.stiffen_time;
d["invisible_time"] = m.invisible_time;
d["external_force"] = m.external_force;
d["hit_limit_count"] = m.hit_limit_count;
// CLIENT-GAP §3.5 修改 1 —— THitDataContainer 多命中窗(多段挥击 / 双持 = 多个窗)。
// [{start_time, end_time, bone, weapon_length, samples:[{time, last_pos:Vector3, pos:Vector3}]}]
Array windows;
for (const fmt::Msa::HitWindow &w : m.hit_windows) {
Dictionary wd;
wd["start_time"] = w.start_time;
wd["end_time"] = w.end_time;
wd["bone"] = String::utf8(w.bone_name.c_str());
wd["weapon_length"] = w.weapon_length;
Array samples;
for (const fmt::Msa::HitSample &s : w.samples) {
Dictionary sd;
sd["time"] = s.time;
sd["last_pos"] = Vector3(s.last_pos[0], s.last_pos[1], s.last_pos[2]);
sd["pos"] = Vector3(s.pos[0], s.pos[1], s.pos[2]);
samples.push_back(sd);
}
wd["samples"] = samples;
windows.push_back(wd);
}
d["hit_windows"] = windows;
return d;
}
int Metin2AnimPlayer::make_combo_key(int motion_mode, int combo_type) {
return (int)fmt::make_combo_key((uint16_t)motion_mode, (uint16_t)combo_type);
}
Dictionary Metin2AnimPlayer::parse_combo_tables(const String &py_path) {
Dictionary out;
fmt::PlayerComboTables tables;
std::string err;
if (!fmt::parse_player_combo_tables_file(
std::string(py_path.utf8().get_data()), tables, &err)) {
UtilityFunctions::push_warning(
String("Metin2AnimPlayer.parse_combo_tables: ") + String(err.c_str()));
return out;
}
for (int c = 0; c < fmt::COMBO_CLASS_COUNT; ++c) {
Dictionary cls;
for (const auto &kv : tables.klass(c).combos) {
PackedInt32Array segs;
segs.resize((int)kv.second.size());
for (int i = 0; i < (int)kv.second.size(); ++i) {
segs.set(i, (int)kv.second[i]);
}
cls[(int)kv.first] = segs;
}
out[c] = cls;
}
return out;
}
void Metin2AnimPlayer::_ready() {
set_process(true);
reload();
}
void Metin2AnimPlayer::reload() {
anim_file.reset();
duration = 0.0;
time = 0.0;
prev_time = 0.0;
next_event = 0;
accumulation = Vector3();
events.clear();
msa_metadata = fmt::Msa{};
last_info = "";
playing = true;
if (anim_path.is_empty()) {
return;
}
// .msa -> resolve the real motion .gr2 + pull accumulation / events.
const bool diagnostics = OS::get_singleton()->get_environment("MTGODOT_ANIM_DIAGNOSTICS") == "1";
String gr2_spec = anim_path;
if (anim_path.to_lower().ends_with(".msa")) {
fmt::Msa m;
std::string e;
if (cached_msa(anim_path, m, &e)) {
msa_metadata = m;
accumulation = Vector3(m.accumulation[0], m.accumulation[1], m.accumulation[2]);
events = m.events;
// Reuse the metadata just parsed; path resolution must not parse it again.
gr2_spec = resolve_anim_gr2(anim_path, m);
if (diagnostics) UtilityFunctions::print(vformat("[Metin2AnimPlayer] .msa -> %s accum=(%.2f %.2f %.2f) events=%d loopdata=%s",
gr2_spec, accumulation.x, accumulation.y, accumulation.z, (int)events.size(),
m.has_loop_data ? vformat("%d x [%.3f,%.3f]", m.motion_loop_count,
m.loop_start_time, m.loop_end_time) : String("none")));
} else {
UtilityFunctions::push_error(String("[Metin2AnimPlayer] .msa: ") + String(e.c_str()));
return;
}
}
if (!motion_loop_override)
motion_loop_count = msa_metadata.has_loop_data ? msa_metadata.motion_loop_count : 0;
gr2::LoadError err;
auto f = cached_clip(gr2_spec, &err);
if (!f) {
UtilityFunctions::push_error(String("[Metin2AnimPlayer] anim load failed [") +
String(err.stage.c_str()) + "]: " + String(err.message.c_str()));
return;
}
const gr2::FileInfo &fi = f->file_info();
if (fi.animations.empty()) {
UtilityFunctions::push_error("[Metin2AnimPlayer] no animations in " + anim_path);
return;
}
anim_file = std::move(f);
const gr2::Animation &an = anim_file->file_info().animations[0];
// 40250 CRaceMotionData::GetMotionDuration() is sourced from the MSA
// MotionDuration field. GR2 contains sampled tracks, but its clip length
// is not the motion-data authority used by actor timing, LoopData and
// MotionEventData. Keep GR2 duration only for raw .gr2 paths or malformed
// MSA files without a positive MotionDuration.
duration = msa_metadata.duration > 0.0 ? msa_metadata.duration : an.duration;
// Diagnostic: how many anim tracks map to a model-skeleton bone by name?
if (Metin2Model *model = diagnostics ? resolve_model() : nullptr) {
if (const gr2::Skeleton *sk = model->gr2_skeleton()) {
int matched = 0;
godot::String unmatched;
for (const auto &tr : an.tracks) {
bool hit = false;
for (const auto &b : sk->bones) {
if (b.name == tr.bone_name) {
hit = true;
break;
}
}
if (hit) {
++matched;
} else if (unmatched.length() < 300) {
unmatched += String(tr.bone_name.c_str()) + " ";
}
}
if (diagnostics) {
UtilityFunctions::print(vformat(
"[Metin2AnimPlayer] track->bone match: %d/%d (skel bones=%d) unmatched: %s",
matched, (int)an.tracks.size(), (int)sk->bones.size(), unmatched));
}
}
}
last_info = vformat("anim '%s' dur=%.3f tracks=%d anims=%d",
String(an.name.c_str()), duration, (int)an.tracks.size(),
(int)anim_file->file_info().animations.size());
if (diagnostics) UtilityFunctions::print("[Metin2AnimPlayer] ", last_info);
apply_pose(0.0);
}
namespace {
// mul4x3 lives in gr2_bridge.h now (shared with the weapon-attach grip compose).
// Blend two local bone transforms: slerp rotation, lerp translation and the
// complete scale/shear basis (PARITY §2.9). Several warrior/sura armour bones
// depend on authored shear, so it must survive the transition as well as steady
// animation sampling.
gr2::Mat4 blend_trs(const gr2::Mat4 &a, const gr2::Mat4 &b, float w) {
godot::Basis ba(godot::Vector3(a[0], a[1], a[2]), godot::Vector3(a[4], a[5], a[6]),
godot::Vector3(a[8], a[9], a[10]));
godot::Basis bb(godot::Vector3(b[0], b[1], b[2]), godot::Vector3(b[4], b[5], b[6]),
godot::Vector3(b[8], b[9], b[10]));
const godot::Quaternion qa = ba.get_rotation_quaternion();
const godot::Quaternion qb = bb.get_rotation_quaternion();
godot::Quaternion q = qa.slerp(qb, w);
// Preserve Granny's complete scale/shear matrix. Keeping only get_scale()
// changes authored joints even at the endpoints and is especially visible in
// hands and shoulders. For B = R*S, extract S and interpolate all 3x3 terms.
const godot::Basis sa = godot::Basis(qa).inverse() * ba;
const godot::Basis sb = godot::Basis(qb).inverse() * bb;
godot::Basis ss;
for (int row = 0; row < 3; ++row) {
ss.rows[row] = sa.rows[row].lerp(sb.rows[row], w);
}
godot::Vector3 tt =
godot::Vector3(a[12], a[13], a[14]).lerp(godot::Vector3(b[12], b[13], b[14]), w);
const godot::Basis rb = godot::Basis(q) * ss;
gr2::Mat4 o{};
o[0] = rb.rows[0].x; o[1] = rb.rows[0].y; o[2] = rb.rows[0].z;
o[4] = rb.rows[1].x; o[5] = rb.rows[1].y; o[6] = rb.rows[1].z;
o[8] = rb.rows[2].x; o[9] = rb.rows[2].y; o[10] = rb.rows[2].z;
o[12] = tt.x; o[13] = tt.y; o[14] = tt.z; o[15] = 1.0f;
return o;
}
// Sample animation tracks into the model skeleton's LOCAL bone domain. Granny
// blends controls before BuildWorldPose; doing the crossfade after world-pose
// accumulation independently interpolates every joint endpoint and temporarily
// changes parent/child distances (visible as stretched or collapsed limbs when
// wait/walk/run switches).
void sample_local_pose(const gr2::Skeleton &sk, const gr2::Animation &an, float t,
std::vector<gr2::Mat4> &local) {
std::vector<gr2::Mat4> track_local;
an.sample_local(t, track_local);
local.resize(sk.bones.size());
for (size_t i = 0; i < sk.bones.size(); ++i) {
local[i] = sk.bones[i].local_transform;
}
for (size_t ti = 0; ti < an.tracks.size() && ti < track_local.size(); ++ti) {
for (size_t bi = 0; bi < sk.bones.size(); ++bi) {
if (sk.bones[bi].name == an.tracks[ti].bone_name) {
local[bi] = track_local[ti];
break;
}
}
}
}
void accumulate_local_pose(const gr2::Skeleton &sk, const std::vector<gr2::Mat4> &local,
const gr2::Mat4 &root_offset, std::vector<gr2::Mat4> &world,
std::vector<gr2::Mat4> &skin) {
static const gr2::Mat4 I{ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 };
const size_t n = sk.bones.size();
world.assign(n, I);
skin.assign(n, I);
for (size_t i = 0; i < n; ++i) {
const int parent = sk.bones[i].parent;
const gr2::Mat4 &parent_world =
(parent < 0 || static_cast<size_t>(parent) >= i) ? root_offset : world[parent];
world[i] = mul4x3(i < local.size() ? local[i] : sk.bones[i].local_transform,
parent_world);
skin[i] = mul4x3(sk.bones[i].inverse_world, world[i]);
}
}
} // namespace
void Metin2AnimPlayer::apply_pose(double t) {
if (!anim_file) {
return;
}
Metin2Model *model = resolve_model();
if (!model) {
return;
}
const gr2::Skeleton *sk = model->gr2_skeleton();
Skeleton3D *skel = model->skeleton_node();
if (!sk || !skel) {
return;
}
const gr2::Animation &an = anim_file->file_info().animations[0];
double tt = t;
if (duration > 0.0 && loop) {
tt = std::fmod(t, duration);
if (tt < 0.0) {
tt += duration;
}
} else if (duration > 0.0) {
tt = std::fmax(0.0, std::fmin(t, duration));
}
// Offset4x4 = identity, NOT the model's exported InitialPlacement.
//
// gr2::sample_pose defaults to sk.initial_placement because that is what the
// oracle passes (GrannyGetModelInitialPlacement4x4) and what the bind pose /
// InverseWorldTransform pair is self-consistent with. Metin2's animation .gr2
// carries the root bone's pelvis height inside its own track, so composing it
// onto InitialPlacement counts that placement twice and lifts the whole actor
// by one pelvis height (warrior 99.4 cm, assassin 92.6, sura 103.7, shaman
// 95.5 — exactly each model's IP.z). That was the "人物悬空" report.
// Measured with tools/rendering foot probe: with identity the lowest skinned
// vertex sits within a few mm of z=0 for wait/walk on all four classes, and
// only leaves the ground during run's airborne frames.
static const gr2::Mat4 kNoOffset{ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 };
bool pose_ready = false;
// Crossfade in LOCAL bone space, then accumulate the hierarchy and rebuild
// skin_buf = invWorld · world. Blending world transforms made bone endpoints
// take independent straight-line paths, shortening limbs around the midpoint.
if (prev_anim_file && blend_time > 0.0 && blend_elapsed < blend_time) {
const gr2::FileInfo &pfi = prev_anim_file->file_info();
if (!pfi.animations.empty()) {
sample_local_pose(*sk, an, (float)tt, local_buf);
sample_local_pose(*sk, pfi.animations[0], (float)prev_anim_time, prev_local_buf);
double w = blend_elapsed / blend_time;
// ease-in on the incoming clip, matching the client's
// GrannySetControlEaseInCurve(t0,t1, 0,0,1,1) Hermite (p0=0,m0=0,
// p1=1,m1=1) -> h(w) = 2w^2 - w^3. Flat start, slope-1 finish.
w = w * w * (2.0 - w);
const size_t n = std::min(local_buf.size(), prev_local_buf.size());
blend_local_buf.resize(local_buf.size());
for (size_t i = 0; i < n; ++i) {
blend_local_buf[i] = blend_trs(prev_local_buf[i], local_buf[i], (float)w);
}
for (size_t i = n; i < local_buf.size(); ++i) {
blend_local_buf[i] = local_buf[i];
}
accumulate_local_pose(*sk, blend_local_buf, kNoOffset, world_buf, skin_buf);
pose_ready = true;
}
}
if (!pose_ready) {
gr2::sample_pose(*sk, an, (float)tt, world_buf, skin_buf, &kNoOffset);
}
// Both skinning paths apply libgr2's per-bone deformer matrices
// (skin_buf = Σ w · invWorld · world) with the FULL affine — shear kept.
// default : CPU LBS, ArrayMesh rebuilt each frame.
// MTGODOT_GPUSKIN=1 : LBS in a custom vertex shader (bone matrices in a
// float texture, no Skeleton3D). See m2_material SRC_SKIN.
// The old Skeleton3D::set_bone_pose route is gone — Godot's built-in skinning
// orthonormalizes the bone matrix and drops the shear (docs/MIDREVIEW.md §4).
static const String kZero("0");
static const String kEnvName("MTGODOT_GPUSKIN");
String env = godot::OS::get_singleton()->get_environment(kEnvName);
// Default to GPU skinning (true) unless explicitly set to "0"
if (env != kZero) {
model->enable_gpu_skin(true);
model->gpu_skin(skin_buf);
} else {
model->enable_cpu_skin(true);
model->cpu_skin(skin_buf);
}
// Rigid weapon follows equip_right_hand's animated world transform (PARITY §2.1).
model->update_weapon_pose(world_buf);
}
void Metin2AnimPlayer::_process(double delta) {
if (!playing || !anim_file) {
return;
}
if (prev_anim_file) {
blend_elapsed += delta; // real seconds, independent of time_scale
if (blend_elapsed >= blend_time) {
prev_anim_file.reset();
}
}
double next = time + delta * time_scale;
// 40250 does not loop the whole clip. For a motion with LoopData it keeps
// the lead-in and tail, and only rewinds LoopStartTime..LoopEndTime while
// the actor's remaining loop count is > 1 (or -1 for infinite looping).
// The legacy actor resets to LoopStartTime at the first frame after the
// loop end, so the frame remainder is intentionally discarded here too.
const bool has_segment_loop = !loop && msa_metadata.has_loop_data &&
motion_loop_count != 0 &&
(motion_loop_count == -1 || motion_loop_count > 1) &&
msa_metadata.loop_end_time > msa_metadata.loop_start_time &&
msa_metadata.loop_end_time <= duration;
if (has_segment_loop && next > msa_metadata.loop_end_time) {
dispatch_events(time, msa_metadata.loop_end_time);
time = msa_metadata.loop_start_time;
if (motion_loop_count > 0)
--motion_loop_count;
apply_pose(time);
// ActorInstance resets dwcurFrame to LoopStartTime and processes
// that frame immediately. Without an inclusive point dispatch,
// events authored exactly at the loop boundary never fire.
dispatch_events(time, time, true);
prev_time = time;
return;
}
if (!loop && duration > 0.0 && next >= duration) {
next = duration;
}
time = next;
apply_pose(time);
dispatch_events(prev_time, time);
prev_time = time;
if (!loop && duration > 0.0 && time >= duration) {
playing = false;
emit_signal("playback_finished");
}
}
// Emit `motion_event` for every .msa event whose start_time falls in (from, to],
// handling loop wrap-around within one clip.
void Metin2AnimPlayer::dispatch_events(double from, double to, bool include_from) {
if (events.empty() || duration <= 0.0) {
return;
}
const double epsilon = 1e-9;
if (to < from - epsilon) {
return;
}
if (!loop) {
double a = std::fmax(0.0, std::fmin(from, duration));
double b = std::fmax(0.0, std::fmin(to, duration));
for (const fmt::MotionEvent &ev : events) {
const bool at_inclusive_start = include_from &&
std::fabs(double(ev.start_time) - a) <= epsilon;
// The first 40250 motion frame is frame 0, not frame 1. The
// normal interval remains half-open on the left so a regular
// frame cannot replay an event on every tick.
const bool initial_zero = !include_from && from <= epsilon &&
to > from + epsilon && std::fabs(double(ev.start_time)) <= epsilon;
if (at_inclusive_start || initial_zero ||
(a < ev.start_time && ev.start_time <= b)) {
emit_motion_event(ev);
}
}
return;
}
// Walk each clip segment independently. The old single-wrap expression
// could emit at most one copy of an event when time_scale/delta crossed
// two or more full durations; ActorInstance processes every frame and
// therefore reaches every crossed MotionEventData entry.
double cursor = from;
double remaining = to - from;
if (remaining <= epsilon) {
if (!include_from) {
return;
}
double point = std::fmod(cursor, duration);
if (point < 0) point += duration;
for (const fmt::MotionEvent &ev : events) {
if (std::fabs(double(ev.start_time) - point) <= epsilon) {
emit_motion_event(ev);
}
}
return;
}
while (remaining > epsilon) {
double a = std::fmod(cursor, duration);
if (a < 0) a += duration;
double segment = std::fmin(remaining, duration - a);
if (segment <= epsilon) {
cursor += duration;
continue;
}
double b = a + segment;
const bool include_segment_start = a <= epsilon;
for (const fmt::MotionEvent &ev : events) {
const bool at_segment_start = include_segment_start &&
std::fabs(double(ev.start_time) - a) <= epsilon;
const bool crossed = a < ev.start_time && ev.start_time <= b;
if (at_segment_start || crossed) {
emit_motion_event(ev);
}
}
cursor += segment;
remaining -= segment;
}
}
String Metin2AnimPlayer::selfcheck(int samples) {
if (!anim_file) {
return "no anim loaded";
}
Metin2Model *model = resolve_model();
const gr2::Skeleton *sk = model ? model->gr2_skeleton() : nullptr;
if (!sk) {
return "no model/skeleton";
}
const auto &anims = anim_file->file_info().animations;
int total = 0;
int nan_hits = 0;
std::vector<gr2::Mat4> w, s;
for (const auto &an : anims) {
double dur = an.duration > 0.0 ? an.duration : 1.0;
for (int k = 0; k <= samples; ++k) {
float tt = (float)(dur * k / samples);
gr2::sample_pose(*sk, an, tt, w, s);
for (const auto &mtx : w) {
for (float v : mtx) {
++total;
if (std::isnan(v) || std::isinf(v)) {
++nan_hits;
}
}
}
}
}
return vformat("selfcheck: anims=%d samples/anim=%d floats=%d NaN/Inf=%d",
(int)anims.size(), samples + 1, total, nan_hits);
}
} // namespace mtgodot
-140
View File
@@ -1,140 +0,0 @@
#pragma once
#include <godot_cpp/classes/node3d.hpp>
#include <godot_cpp/variant/dictionary.hpp>
#include <godot_cpp/variant/node_path.hpp>
#include <godot_cpp/variant/string.hpp>
#include <godot_cpp/variant/vector3.hpp>
#include <gr2/gr2.h>
#include <msa.h>
#include <optional>
#include <memory>
#include <vector>
namespace mtgodot {
class Metin2Model;
// Drives a Metin2Model each frame by sampling a (possibly separate) animation
// .gr2 with libgr2, then skinning it with the resulting matrices:
//
// sample_pose(model.skeleton, anim, t) -> world[i], skin[i]
// skin[i] = inverse_world[i] * world[i] (gr2 deformer matrix per bone)
//
// The skinning itself is done in Metin2Model (NOT via Skeleton3D — Godot's
// built-in skinning orthonormalizes the bone matrix and drops the shear Granny
// bakes onto some rig bones; see docs/MIDREVIEW.md §4):
// - default : cpu_skin(skin[]) — CPU LBS, ArrayMesh rebuilt/frame
// - MTGODOT_GPUSKIN=1 : gpu_skin(skin[]) — full 4x3 matrices in a float
// texture, LBS in the SRC_SKIN vertex shader
class Metin2AnimPlayer : public godot::Node3D {
GDCLASS(Metin2AnimPlayer, godot::Node3D)
public:
Metin2AnimPlayer();
~Metin2AnimPlayer() override;
void _ready() override;
void _process(double delta) override;
void set_anim_path(const godot::String &p);
godot::String get_anim_path() const { return anim_path; }
void set_model_path(const godot::NodePath &p);
godot::NodePath get_model_path() const { return model_path; }
void set_playing(bool v) { playing = v; }
bool get_playing() const { return playing; }
void set_loop(bool v) { loop = v; }
bool get_loop() const { return loop; }
void set_time_scale(double s) { time_scale = s; }
double get_time_scale() const { return time_scale; }
// 40250 CActorInstance::SetMotionLoopCount. A positive value overrides the
// current .msa LoopData count; zero restores the resource-defined count.
void set_motion_loop_count(int count);
int get_motion_loop_count() const { return motion_loop_count; }
// Crossfade duration (s) applied when anim_path changes while a clip is
// already playing (PARITY §2.9). 0 = hard cut (old behaviour).
void set_blend_time(double s) { blend_time = s < 0.0 ? 0.0 : s; }
double get_blend_time() const { return blend_time; }
void set_time(double t);
double get_time() const { return time; }
double get_duration() const { return duration; }
void reload();
godot::String get_info() const { return last_info; }
// .msa metadata (empty / zero when anim_path is a raw .gr2).
godot::Vector3 get_accumulation() const { return accumulation; }
godot::Array get_events() const; // detailed MotionEvent dictionaries, including type-specific payloads
// Full affine pose in model GR2 coordinates, shared by CPU/GPU and blending.
godot::Dictionary get_effect_bone_pose(const godot::String &bone);
godot::Dictionary get_loop_data() const;
godot::Dictionary get_motion_data() const; // {duration,next_combo,attack_start_time,...}
static godot::Dictionary get_clip_cache_stats();
static void clear_clip_cache();
// CLIENT-GAP §3.5 — parse the PC combo tables out of playersettingmodule.py
// (chrmgr.ReserveComboAttackNew / RegisterComboAttackNew → CRaceData). Returns
// { class_idx:int -> { combo_key:int -> PackedInt32Array(段号 14..21) } }
// with class_idx 0=warrior 1=assassin 2=sura 3=shaman and
// combo_key = (motion_mode << 16) | combo_type. Empty Dictionary on failure.
static godot::Dictionary parse_combo_tables(const godot::String &py_path);
// (motion_mode << 16) | combo_type — mirrors RaceData.h MAKE_COMBO_KEY.
static int make_combo_key(int motion_mode, int combo_type);
// NaN-scan every animation in anim_path across [0,dur]; returns a report string.
godot::String selfcheck(int samples = 24);
protected:
static void _bind_methods();
private:
void emit_motion_event(const fmt::MotionEvent &ev);
godot::String anim_path;
godot::NodePath model_path;
bool playing = true;
bool loop = true; // whole-clip playback mode; independent from .msa LoopData
double time_scale = 1.0;
int motion_loop_count = 0;
bool motion_loop_override = false;
double time = 0.0;
double duration = 0.0;
godot::String last_info;
std::shared_ptr<const gr2::File> anim_file;
std::vector<gr2::Mat4> world_buf;
std::vector<gr2::Mat4> skin_buf;
// Crossfade state: the outgoing clip is kept and sampled at a frozen time,
// its pose blended into the incoming clip for `blend_time` seconds.
double blend_time = 0.15;
std::shared_ptr<const gr2::File> prev_anim_file;
double prev_anim_time = 0.0;
double prev_anim_duration = 0.0;
bool prev_anim_loop = true;
double blend_elapsed = 0.0;
// Crossfades operate before hierarchy accumulation so parent/child lengths
// remain stable while switching locomotion clips.
std::vector<gr2::Mat4> local_buf, prev_local_buf, blend_local_buf;
godot::Vector3 accumulation; // .msa Accumulation (root motion)
std::vector<fmt::MotionEvent> events;
fmt::Msa msa_metadata;
int next_event = 0; // index into `events`, for _process dispatch
double prev_time = 0.0;
Metin2Model *resolve_model() const;
void apply_pose(double t);
void dispatch_events(double from, double to, bool include_from = false);
// .msa path -> real motion .gr2 path; passes plain .gr2 paths through.
static godot::String resolve_anim_gr2(const godot::String &spec, const fmt::Msa &metadata);
};
} // namespace mtgodot
File diff suppressed because it is too large Load Diff
-277
View File
@@ -1,277 +0,0 @@
#pragma once
#include <godot_cpp/classes/array_mesh.hpp>
#include <godot_cpp/classes/image.hpp>
#include <godot_cpp/classes/image_texture.hpp>
#include <godot_cpp/classes/node3d.hpp>
#include <godot_cpp/classes/ref.hpp>
#include <godot_cpp/templates/hash_map.hpp>
#include <godot_cpp/variant/array.hpp>
#include <godot_cpp/variant/aabb.hpp>
#include <godot_cpp/variant/dictionary.hpp>
#include <godot_cpp/variant/packed_string_array.hpp>
#include <godot_cpp/variant/string.hpp>
#include <gr2/gr2.h>
#include "gr2_bridge.h"
#include <memory>
#include <optional>
#include <vector>
namespace godot {
class Skeleton3D;
class MeshInstance3D;
} // namespace godot
namespace mtgodot {
// Loads a Metin2 .gr2 and builds a Godot subtree:
// Metin2Model (transform = Z-up/cm -> Y-up/m conversion)
// └─ Skeleton3D (rest pose from gr2 bones)
// └─ MeshInstance3D (ArrayMesh, one surface per gr2 mesh; skinned)
//
// M1: static bind-pose render. M2's Metin2AnimPlayer drives the Skeleton3D.
class Metin2Model : public godot::Node3D {
GDCLASS(Metin2Model, godot::Node3D)
public:
Metin2Model();
~Metin2Model() override;
void _ready() override;
void _process(double delta) override;
// --- inspector properties ---
// §2.10 LOD: loads <base>_lod_01/02/03.gr2 (same 75-bone skeleton, decimated
// meshes) and swaps the rendered mesh by camera distance. `lod_distances` =
// [d1,d2,d3]; >d3 -> LOD3. Skinning is unaffected (shared skeleton).
void set_lod_enabled(bool v);
bool get_lod_enabled() const { return lod_enabled; }
void set_lod_distances(const godot::PackedFloat32Array &d);
godot::PackedFloat32Array get_lod_distances() const { return lod_dist; }
int get_lod_level() const { return lod_level; }
void set_gr2_path(const godot::String &p);
godot::String get_gr2_path() const { return gr2_path; }
void set_texture_dir(const godot::String &p);
godot::String get_texture_dir() const { return texture_dir; }
void set_unit_scale(double s);
double get_unit_scale() const { return unit_scale; }
void set_flip_z(bool v);
bool get_flip_z() const { return flip_z; }
void set_flip_winding(bool v);
bool get_flip_winding() const { return flip_winding; }
void set_material_mode(const godot::String &m);
godot::String get_material_mode() const { return material_mode; }
void set_use_gr2_materials(bool v);
bool get_use_gr2_materials() const { return use_gr2_materials; }
// Sphere-map specular power (PARITY §2.7 / BACKLOG B9). In the original client
// this is per skin-part, driven by the equipped body-armor item_proto
// `bSpecular / 100`; 0 = flat (the base body). No equipment layer here yet, so
// this is a manual hook — 0 keeps output identical to before.
void set_specular_power(double p);
double get_specular_power() const { return specular_power; }
// Explicit per-surface texture path override (index -> absolute .dds path).
void set_surface_texture(int surface, const godot::String &path);
// Reference SetMaterialImagePointer(part, SourceSkin, TargetSkin): replace
// every GR2 material whose texture basename matches source, rather than
// assuming that an armour skin always belongs to surface 0.
void set_skin_texture(const godot::String &source, const godot::String &target);
void clear_skin_textures();
// When gr2_path is a .msm: [{index, model (abs .gr2), target_skin}, ...].
godot::Array get_hair_options() const { return hair_options; }
// Attach a hair .gr2 whose skeleton bone names match the base. Its mesh is
// merged into the CPU-skin path (skinned by the base skeleton's matrices,
// bones remapped by name). "" detaches. GPU-skin path ignores hair for now.
void set_hair_gr2(const godot::String &p);
godot::String get_hair_gr2() const { return hair_gr2; }
// SourceSkin -> TargetSkin recolour (PARITY §2.4 / BACKLOG D3). The client's
// `.msm` HairData ships one hair .gr2 with a `SourceSkin` (the texture baked
// into the gr2 material) and a per-colour `TargetSkin` dds; `SetMaterialImage
// Pointer(part, SourceSkin, load(TargetSkin))` swaps it. Here: when set, the
// hair mesh uses this dds as its albedo instead of the gr2's sibling texture.
// Absolute path (an entry's resolved `target_skin` from get_hair_options()).
void set_hair_skin(const godot::String &p);
godot::String get_hair_skin() const { return hair_skin; }
// Attach a rigid weapon .gr2 to a base-skeleton bone (PARITY §2.1 / BACKLOG
// C5). The client links the weapon model instance to `equip_right_hand`
// (`playersettingmodule.py`, warrior) and drives it with that bone's world
// matrix (`ModelInstanceUpdate.cpp:148` GetBoneMatrixPointer). Here the weapon
// mesh is a child MeshInstance3D whose transform = the bone's world pose from
// gr2::sample_pose each frame. "" detaches. Path may be relative to the base
// gr2 ("d:/ymir work/item/weapon/00040.gr2") or absolute.
void set_weapon_gr2(const godot::String &p);
godot::String get_weapon_gr2() const { return weapon_gr2; }
void set_weapon_bone(const godot::String &b);
godot::String get_weapon_bone() const { return weapon_bone; }
// Off-hand shield (rigid, attaches to equip_left_hand like the weapon).
void set_shield_gr2(const godot::String &p);
godot::String get_shield_gr2() const { return shield_gr2; }
void set_shield_bone(const godot::String &b);
godot::String get_shield_bone() const { return shield_bone; }
// Fed by Metin2AnimPlayer after each gr2::sample_pose: base-skeleton world
// matrices (world_pose output). Repositions attached rigid parts (weapon +
// shield). No-op for a slot with no gr2 or an unresolved bone name.
void update_weapon_pose(const std::vector<gr2::Mat4> &world_pose);
// Rebuild the subtree from the current properties.
void reload();
// One-line summary (bones / meshes / verts / bounds).
godot::String get_info() const;
// Deformed mesh bounds in this node's local GR2 coordinate frame. Unlike
// MeshInstance3D::get_aabb(), this follows the GPU shader pose as well.
godot::AABB get_visual_aabb();
godot::Dictionary get_fly_target_bounds();
// Parent-space Y adjustment that places the bind-pose model's lowest point
// on the actor origin. GR2 is Z-up/cm; this is converted to Godot Y/metres.
double get_ground_offset() const { return ground_offset; }
// --- C++ accessors for Metin2AnimPlayer (same extension) ---
const gr2::Skeleton *gr2_skeleton() const;
godot::Skeleton3D *skeleton_node() const { return skel; }
const gr2::FileInfo *gr2_fileinfo() const;
// Latest full-affine deformer matrices. Initialized to bind pose on reload
// and refreshed by either skinning path; attachments can share this pose.
const std::vector<gr2::Mat4> &current_skin_matrices() const { return current_skin; }
// CPU linear-blend skinning: rewrite ArrayMesh vertex regions from the
// per-bone deformer matrices (gr2::sample_pose's `skin` output). Debug/
// reference path; bypasses Godot's Skeleton3D skinning entirely.
void cpu_skin(const std::vector<gr2::Mat4> &skin);
bool has_cpu_skin_mesh() const { return cpu_mesh.is_valid(); }
void enable_cpu_skin(bool on);
// GPU linear-blend skinning: LBS in a custom vertex shader with the FULL
// per-bone matrices uploaded as a float texture (no Skeleton3D -> shear kept).
// Keeps the static build_mesh() output; just swaps materials + feeds the
// bone texture each frame. MTGODOT_GPUSKIN=1 route.
void enable_gpu_skin(bool on);
void gpu_skin(const std::vector<gr2::Mat4> &skin);
// libgr2 sanity probe (kept from M0').
godot::String probe_gr2(const godot::String &path) const;
static void clear_texture_cache();
protected:
static void _bind_methods();
private:
godot::String gr2_path;
godot::String texture_dir;
double unit_scale = 0.01;
bool flip_z = false;
bool flip_winding = false;
godot::String material_mode = "metin2"; // "metin2" (ShaderMaterial) | "standard"
bool use_gr2_materials = true; // gr2 MaterialBindings -> texture (else: filename heuristic only)
double specular_power = 0.0; // PARITY §2.7; 0 = disabled
godot::Ref<godot::Texture2D> sphere_map; // shared sphere map, lazy-loaded
godot::Ref<godot::Texture2D> _load_sphere_map(); // "ymir work/special/spheremap.jpg"
godot::PackedStringArray surface_tex_override;
godot::Dictionary skin_tex_override; // lowercase source basename -> target path
godot::String resolved_gr2_dir; // dir of the actually-loaded .gr2 (for .msm)
godot::Array hair_options; // from .msm HairData
godot::String hair_gr2; // attached hair .gr2 (or "")
godot::String hair_skin; // TargetSkin dds override (or "")
std::shared_ptr<std::optional<gr2::File>> hair_file; // loaded hair gr2
std::vector<mtgodot::RenderPart> hair_parts;
std::vector<int> hair_bone_remap; // hair skel idx -> base skel idx (by name)
godot::Ref<godot::Material> hair_mat;
godot::Ref<godot::ImageTexture> hair_tex; // resolved hair albedo (for GPU path)
int gpu_base_surf = 0; // base surfaces before appended GPU hair
void _load_hair();
void _refresh_hair_mesh();
void _build_gpu_mesh(); // base + remapped hair surfaces -> mi->mesh (GPU-skin path, §2.2)
godot::String weapon_gr2; // attached weapon .gr2 (or "")
godot::String weapon_bone = "equip_right_hand"; // base-skeleton attach bone
std::shared_ptr<std::optional<gr2::File>> weapon_file; // loaded weapon gr2
godot::MeshInstance3D *weapon_mi = nullptr; // child of this node
int weapon_bone_idx = -1; // base skeleton index of weapon_bone
// Weapon root rigid-mesh composite (invWorld · local),
// folded in before the parent hand matrix exactly as Granny does for an attached
// model. This may contain a large translation which cancels an authored vertex
// offset (notably 03150), so it must not be clamped.
gr2::Mat4 weapon_pre{ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 };
godot::String shield_gr2;
godot::String shield_bone = "Bip01 L Hand"; // PC skeletons have no equip_left_hand
std::shared_ptr<std::optional<gr2::File>> shield_file;
godot::MeshInstance3D *shield_mi = nullptr;
int shield_bone_idx = -1;
gr2::Mat4 shield_pre{ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 };
void _load_weapon();
void _load_shield();
// shared rigid-attach loader used by _load_weapon / _load_shield.
void _load_attach(const godot::String &gr2_rel, const godot::String &bone_name,
std::shared_ptr<std::optional<gr2::File>> &slot_file, godot::MeshInstance3D *&slot_mi,
int &slot_bone_idx, gr2::Mat4 &slot_pre, const char *node_name, const char *label);
std::shared_ptr<std::optional<gr2::File>> file; // shared_ptr so accessor stays valid
std::vector<gr2::MaterialInfo> materials;
godot::Skeleton3D *skel = nullptr;
godot::MeshInstance3D *mi = nullptr;
godot::String last_info;
double ground_offset = 0.0;
// §2.10 LOD
bool lod_enabled = true;
int lod_level = 0; // 0 = full model, 1..3 = _lod_0N
godot::PackedFloat32Array lod_dist;
std::vector<std::shared_ptr<std::optional<gr2::File>>> lod_files; // [0]=_lod_01 ...
std::vector<std::vector<mtgodot::RenderPart>> lod_parts; // parallel to lod_files
// LOD crossfade: a frozen ghost of the outgoing level fades out while the new
// mesh fades in (LODController::BlendRenderWithOneTexture). ~0.18 s.
godot::MeshInstance3D *lod_prev_mi = nullptr;
double lod_fade_t = -1.0; // <0 = not fading
std::vector<godot::Ref<godot::Material>> lod_ghost_mats;
void _end_lod_fade();
std::vector<mtgodot::RenderPart> base_parts;
void _load_lods(const godot::String &loaded_spec);
void _set_lod(int n);
const gr2::FileInfo *active_fi() const; // base or current LOD (mesh/material data)
std::vector<mtgodot::RenderPart> parts; // surface s -> (gr2 mesh, tri_group, material)
std::vector<godot::Ref<godot::Material>> surf_mats; // resolved once; re-assigned each cpu_skin frame
godot::Ref<godot::ArrayMesh> cpu_mesh; // rebuilt each frame in cpu_skin mode
bool gpu_skin_active = false;
bool gpu_visual_bounds_ready = false;
bool have_visual_bounds = false;
godot::AABB visual_bounds;
std::vector<gr2::Mat4> current_world_pose;
godot::AABB fly_target_bounds;
bool fly_target_bounds_valid = false;
std::vector<gr2::Mat4> current_skin;
godot::Ref<godot::Image> bones_img; // RGBAF 3 x bone_count
godot::Ref<godot::ImageTexture> bones_tex;
void _clear_children();
void _apply_materials();
bool _update_skinned_bounds(const std::vector<gr2::Mat4> &skin);
godot::String _guess_texture_dir() const;
// Resolve a "d:/ymir work/..." path referenced from `base` (.msm/.msa) to an
// absolute file. Passes existing absolute paths through.
static godot::String resolve_rel_gr2(const godot::String &base, const godot::String &spec);
godot::Ref<godot::ImageTexture> _load_dds(const godot::String &path);
godot::Ref<godot::ImageTexture> _resolve_texture(const godot::String &dir,
const godot::String &stem, const godot::String &surface_name,
const godot::PackedStringArray &dds_files);
};
} // namespace mtgodot
File diff suppressed because it is too large Load Diff
-198
View File
@@ -1,198 +0,0 @@
#pragma once
#include <godot_cpp/classes/image.hpp>
#include <godot_cpp/classes/node3d.hpp>
#include <godot_cpp/classes/ref.hpp>
#include <godot_cpp/variant/array.hpp>
#include <godot_cpp/variant/dictionary.hpp>
#include <godot_cpp/variant/string.hpp>
#include <asset_resolver.h>
#include <environment.h>
#include <map_setting.h>
#include <property.h>
#include <terrain_files.h>
#include <texture_set.h>
#include <memory>
#include <cstdint>
#include <vector>
#include "static_object.h"
namespace godot {
class MeshInstance3D;
}
namespace mtgodot {
// W1 —— 加载一张 Metin2 户外地图并渲染灰色高度地形。
// SHINSOO-WORLD-RENDERING.md §8(Metin2World API 契约)/ §9-W1。
//
// Metin2World (Node3D)
// └─ Terrain_000000 (MeshInstance3D) …每区块一个
//
// 后续阶段往这里加 splat 材质(W2)、静态对象(W3)、树(W4)、环境(W5)…
class Metin2World : public godot::Node3D {
GDCLASS(Metin2World, godot::Node3D)
public:
struct HeightTriangle {
godot::Vector3 v0;
godot::Vector3 v1;
godot::Vector3 v2;
float min_x = 0.0f, max_x = 0.0f;
float min_z = 0.0f, max_z = 0.0f;
godot::Vector3 normal;
float d = 0.0f;
};
Metin2World();
~Metin2World() override;
void _ready() override;
void _process(double delta) override;
void set_assets_root(const godot::String &p);
godot::String get_assets_root() const { return assets_root; }
void set_map_path(const godot::String &p);
godot::String get_map_path() const { return map_path; }
void set_load_radius_tiles(int r) { load_radius = r; }
int get_load_radius_tiles() const { return load_radius; }
void set_focus_tile(godot::Vector2i t);
void set_initial_focus_cm(godot::Vector2 position) { initial_focus_cm = position; }
godot::Vector2i get_focus_tile() const { return godot::Vector2i(focus_tx, focus_ty); }
void set_auto_load(bool v) { auto_load = v; }
bool get_auto_load() const { return auto_load; }
void set_splat_enabled(bool v) { splat_enabled = v; }
bool get_splat_enabled() const { return splat_enabled; }
void set_terrain_patches(int n) { terrain_patches = n; }
int get_terrain_patches() const { return terrain_patches; }
void set_objects_enabled(bool v) { objects_enabled = v; }
bool get_objects_enabled() const { return objects_enabled; }
void set_env_enabled(bool v) { env_enabled = v; }
bool get_env_enabled() const { return env_enabled; }
void set_water_enabled(bool v) { water_enabled = v; }
void set_collision_enabled(bool v) { collision_enabled = v; }
bool get_water_enabled() const { return water_enabled; }
// §3.3: static building/tree shadows are already baked into shadowmap.dds, so
// they don't cast realtime sun shadows by default (avoids double-darkening).
// Only shadowflag=1 buildings and dynamic actors cast realtime. Flip these on
// for a modernized look.
void set_tree_shadows(bool v) { tree_shadows = v; }
bool get_tree_shadows() const { return tree_shadows; }
void set_static_shadows(bool v) { static_shadows = v; }
bool get_static_shadows() const { return static_shadows; }
void set_stream_budget(int v) { stream_budget = v < 1 ? 1 : v; }
int get_stream_budget() const { return stream_budget; }
bool load_map();
void unload_map();
// 流式:把关注点设到某全局米坐标;load_radius_tiles >= 0 时按 3×3(radius) 装/卸区块。
void set_focus_position(double gx_m, double gz_m);
godot::Dictionary get_perf() const; // fps / frame ms / draw calls / prims / vram / 节点数
// 全局米坐标(Godot 空间)下的地表高度;地图外返回 0。
godot::Vector2 get_map_base_cm() const; // setting.txt BasePosition, in cm
godot::Vector2i get_map_size_tiles() const;
double sample_height(double gx_m, double gz_m) const;
// attr.atr 属性字节(bit0=BLOCK, bit1=WATER…);地图外返回 0。
int sample_attribute(double gx_m, double gz_m) const;
bool is_blocked(double gx_m, double gz_m) const { return (sample_attribute(gx_m, gz_m) & 1) != 0; }
// ClientVS22 InstanceBaseMotion::GetFishingRot 等价:在角色前方 600cm
// 按当前 heading ±(0..180°,步长 10°) 扫描 ATTRIBUTE_WATER。
// 返回选中的服务端 heading;没有水面或目标区块未加载时返回 -1。
double get_fishing_rotation(double gx_m, double gz_m, double heading_deg) const;
bool can_fishing_position(double gx_m, double gz_m, double heading_deg) const;
godot::Dictionary get_load_report() const;
// Area ambience sources from AreaAmbienceData + .pra properties. Audio owns
// playback; the world only exposes the reference source records.
godot::Array get_ambience_sources() const;
// 便捷:解一张 DDS 为 Image(HUD 小地图等用;Godot 原生不支持 .dds)。
godot::Ref<godot::Image> load_dds(const godot::String &path) const;
// map_path 下某区块目录的绝对路径(HUD 找 minimap.dds 用)。
godot::String chunk_dir(int tile_x, int tile_y) const;
// 构建期用:扫 assets_root 建 AssetResolver 索引,写到 out_path(asset_index.txt)。
// 打进 PCK 后移动端 build_or_load() 直接装载,不再 std::filesystem 扫盘。
bool bake_asset_index(const godot::String &out_path);
protected:
static void _bind_methods();
private:
godot::String assets_root;
godot::String map_path = "OutdoorA1/metin2_map_a1";
int load_radius = -1; // <0 = 全图
int focus_tx = 0, focus_ty = 0;
godot::Vector2 initial_focus_cm;
bool auto_load = true;
bool splat_enabled = true;
// §3.5: 每区块地形拆成 N×N patch(各自 MeshInstance),启用逐 patch 视锥剔除 +
// visibility_range 远距整片剔除。1 = 不拆(旧行为)。必须整除 128。
int terrain_patches = 4;
// patch 超过这个距离整片不画(米)。默认 3500 覆盖全图+俯视调试,实际增益来自
// 逐 patch 视锥剔除;游戏内可调低省远景地形。
float terrain_patch_view = 3500.0f;
bool objects_enabled = true;
bool env_enabled = true;
bool water_enabled = true;
bool collision_enabled = true;
bool tree_shadows = false; // trees: baked in shadowmap.dds -> no realtime cast
bool static_shadows = false; // shadowflag=0/empty buildings: same
int water_pieces = 0;
fmt::MapSetting setting;
fmt::TextureSet texture_set;
fmt::Environment env;
bool env_ok = false;
std::shared_ptr<fmt::AssetResolver> resolver;
fmt::PropertyRegistry registry;
StaticMeshCache static_cache;
bool setting_ok = false;
bool splat_ready = false;
bool registry_ok = false;
godot::String last_error;
int chunks_built = 0, chunks_failed = 0, chunks_splatted = 0;
int objects_placed = 0, objects_skipped = 0, objects_missing_model = 0;
int trees_placed = 0, tree_species = 0;
int native_trees_placed = 0, proxy_trees_placed = 0;
int objects_mdatr_built = 0;
godot::Node3D *objects_root = nullptr;
double build_ms = 0;
struct Chunk {
int tx = 0, ty = 0;
std::shared_ptr<fmt::HeightMap> hm;
std::shared_ptr<fmt::AttrMap> am;
godot::Node3D *root = nullptr; // 该区块的全部场景节点(terrain + water + 对象 + 树)
int objects = 0, trees = 0;
std::vector<HeightTriangle> height_triangles;
};
struct AmbienceSource {
int tile_x = 0, tile_y = 0;
int object_index = 0;
godot::Vector3 position;
int range_cm = 0;
float max_volume_area_percentage = 0.0f;
float play_interval = 0.0f;
float play_interval_variation = 0.0f;
godot::String play_type;
std::vector<std::string> sounds;
};
std::vector<Chunk> chunks;
std::vector<std::pair<int, int>> stream_queue; // 待建区块
std::vector<AmbienceSource> ambience_sources;
int stream_budget = 1; // 每帧最多建几个区块(streaming 时)
godot::String map_dir() const;
bool build_chunk(int tx, int ty);
void place_chunk_objects(int tx, int ty, godot::Node3D *root, int &n_obj, int &n_tree,
std::vector<HeightTriangle> &out_heights);
void place_chunk_ambience(int tx, int ty);
void unload_chunk(int idx);
void stream_update();
const Chunk *chunk_at(int tx, int ty) const;
};
} // namespace mtgodot
File diff suppressed because it is too large Load Diff
-433
View File
@@ -1,433 +0,0 @@
#pragma once
// M2Client — GDExtension Node wrapping the Metin2 net client. GDScript drives it:
//
// var c = M2Client.new()
// add_child(c)
// c.phase_changed.connect(func(p): print("phase ", p))
// c.char_list.connect(func(list): c.select_character(list[0]["index"]))
// c.entered_game.connect(func(): print("in game"))
// c.connect_to_server("192.168.21.203", 11000, "192.168.21.203", 11011,
// "admin", "123456789")
//
// Orchestration: AuthClient(auth_host:auth_port) -> on auth_ok, connect
// GameClient(game_host:game_port) which does its own KX handshake, sends
// CG_LOGIN2, surfaces the character list, then select_character() drives to the
// game phase. _process() pumps whichever stream is active.
#include <godot_cpp/classes/image.hpp>
#include <godot_cpp/classes/node.hpp>
#include <godot_cpp/core/object_id.hpp>
#include <godot_cpp/classes/ref.hpp>
#include <godot_cpp/variant/array.hpp>
#include <godot_cpp/variant/dictionary.hpp>
#include <godot_cpp/variant/packed_byte_array.hpp>
#include <godot_cpp/variant/string.hpp>
#include <memory>
#include <unordered_set>
#include <vector>
namespace mtnet {
class AuthClient;
class GameClient;
class MarkClient;
class MarkImageSet;
class EntityStore;
namespace classic {
class ClassicSession;
class ClassicMarkClient;
}
} // namespace mtnet
namespace mtgodot {
class M2Client : public godot::Node {
GDCLASS(M2Client, godot::Node)
public:
M2Client();
~M2Client() override;
void _process(double delta) override;
// host/port for auth and game servers; account credentials.
void connect_to_server(const godot::String &auth_host, int auth_port,
const godot::String &game_host, int game_port, const godot::String &id,
const godot::String &pw);
// LoginPhase retry equivalent of LoginWindow.Connect. The implementation
// reuses a pending classic session and replaces only its auth transport.
void retry_login(const godot::String &auth_host, int auth_port,
const godot::String &game_host, int game_port, const godot::String &id,
const godot::String &pw);
void disconnect_from_server();
// Share the already-loaded item_proto with native action gates. The object
// is held by instance id so unloading a scene cannot leave a dangling ptr.
void set_item_proto(godot::Object *proto);
// Resolve the same item use/drop sound CPythonItem selects from item_proto.
// This small public seam keeps the resource-side branch directly testable.
godot::String item_action_sound_for_item(int vnum, bool drop) const;
// pick a slot from the char_list payload (its "index" field).
bool select_character(int index);
// Enter a character through the stock two-connection direct-enter flow.
bool enter_game(int index);
// CG_CHARACTER_CREATE / CG_CHARACTER_DELETE (mirror SendCreate/DestroyCharacterPacket).
bool create_character(int slot, const godot::String &name, int job, int shape,
int con, int intel, int str, int dex);
bool delete_character(int slot, const godot::String &private_code);
// Rename a character slot with a server-side rename card.
bool change_name(int slot, const godot::String &name);
// GC_EMPIRE (0 = server still wants an empire pick before select).
int get_empire() const;
// CG_EMPIRE: choose one of the three empires when the account has no empire.
// Both network backends expose the same intent for the character-select UI.
bool select_empire(int empire);
// 3 or 4 depending on which GC_LOGIN_SUCCESS the server sent.
int get_slot_count() const;
// --- in-game intents (mirror CPythonNetworkStream) ---
// rot_deg is a compass heading in degrees; wire form is rot_deg/5 like the
// original client. x/y are server cm. time is filled from the frame clock.
bool move(int func, int arg, double rot_deg, int x, int y);
// Sends CG_CHARACTER_POSITION (legacy posture/position enum, 0..255).
bool character_position(int position);
// Sends a CG_SYNC_POSITION batch. Each entry is {vid, x, y} in server cm;
// the legacy packet allows at most 16 entries.
bool sync_positions(const godot::Array &positions);
bool request_warp();
bool fishing(double rot_deg);
bool request_dungeon();
bool attack(int motion, int victim_vid);
bool set_target(int victim_vid);
bool say(int type, const godot::String &text);
// Reference client SendEmoticon: send the registered chat token and let the
// presentation layer play the matching local effect.
bool send_emoticon(int index);
bool whisper(const godot::String &to, const godot::String &text);
// cast: CG_MOVE with func = FUNC_SKILL(0x80) | (motion_idx & 0x7F),
// arg = loop count low nibble | moving-skill bit (1 << 4).
bool cast_skill(int motion_idx, double rot_deg, int x, int y, int arg = 0);
// Real skill intent. The non-classic backend may include its primary target
// packet here; the classic 40250 caller sends primary fly targeting from the
// bow event and uses this method for CG_USE_SKILL itself.
bool use_skill(int skill_id, int target_vid);
// Ranged animation events and area-target selection use separate packets.
bool shoot(int skill_id);
// Primary bow/skill fly target: CG_FLY_TARGETING. Area skills use the
// separate CG_ADD_FLY_TARGETING path below.
bool fly_targeting(int target_vid, int x, int y);
bool add_fly_targeting(int target_vid, int x, int y);
// server-driven quest command: "/skillup <skill_id>".
bool skill_up(int skill_id);
int get_skill_group() const;
godot::Array get_skills() const; // [{id, level, master}] for skills the player has
godot::Array get_quickslots() const; // [{pos, type, ref}] restored quickslots
bool quickslot_add(int pos, int type, int ref);
bool quickslot_del(int pos);
bool quickslot_swap(int pos, int change_pos);
// quest / NPC
bool click_npc(int vid);
bool script_answer(int answer); // dialog choice (0..N-1) or 255 = continue
bool script_button(int idx); // quest-log button
bool script_select_item(int selection); // inventory cell/item position
bool quest_input(const godot::String &text);
bool quest_confirm(bool yes, int request_pid);
bool quest_cancel();
godot::Array get_quests() const; // [{index, title, counter_name, counter_value, ...}]
// --- P8 party ---
bool party_invite(int vid);
bool party_answer(int leader_pid, bool accept);
bool party_leave(int pid); // expel <pid>, or your own pid to leave
bool party_use_skill(int skill_index, int target_vid);
bool party_set_distribute(int mode);
bool party_set_state(int pid, int role, bool on); // CG_PARTY_SET_STATE (role = PARTY_ROLE_*)
godot::Array get_party() const; // [{pid, vid, name, leader, hp_pct, state, affects[7]}]
int get_party_distribute_mode() const;
// --- P8 messenger / friends ---
bool add_friend(const godot::String &name);
bool remove_friend(const godot::String &name);
bool friend_answer(const godot::String &name, bool accept);
godot::Array get_friends() const; // [{name, online}]
godot::Dictionary get_lover() const; // {valid, name, love_point}
// --- P8 NPC shop ---
bool shop_buy(int pos, int count);
bool shop_sell(int inv_cell, int count);
bool shop_close();
bool is_shop_open() const;
godot::Array get_shop_items() const; // [{pos, vnum, price, count}] — tab 0
godot::Dictionary get_shop() const; // {vid, open, tabs:[{name, coin_type, items:[...]}]}
// --- P8 exchange / trade ---
bool exchange_start(int vid);
bool exchange_add_item(int inv_window, int inv_cell, int display_pos);
bool exchange_add_gold(int gold);
bool exchange_accept();
bool exchange_cancel();
godot::Dictionary get_exchange() const; // {active, partner_vid, self_items, peer_items, ...}
// --- P8 safebox / storage ---
bool safebox_checkin(int safe_pos, int inv_window, int inv_cell);
bool safebox_checkout(int safe_pos, int inv_window, int inv_cell);
bool safebox_move(int from_cell, int to_cell, int count);
bool is_safebox_open() const;
int get_safebox_size() const;
int get_safebox_gold() const;
godot::Array get_safebox_items() const; // [{cell, vnum, count}]
bool safebox_password(const godot::String &password);
// 40250 的仓库关闭/改密走 chat 命令(cmd.cpp do_safebox_close /
// do_safebox_change_password,均 GM_PLAYER),与 safebox_password 同通道。
bool safebox_change_password(const godot::String &old_password,
const godot::String &new_password);
bool safebox_close();
// --- item-mall (창고몰) ---
bool is_mall_open() const;
int get_mall_size() const;
godot::Array get_mall_items() const; // [{cell, vnum, count}]
bool mall_checkout(int mall_pos, int inv_window, int inv_cell);
bool mall_password(const godot::String &password);
bool mall_close(); // "/mall_close"(cmd.cpp do_mall_close)
// --- private (PC) shop ---
// items: Array of {vnum, count, inv_cell, price, display_pos}
bool open_private_shop(const godot::String &sign, const godot::Array &items);
bool close_private_shop();
// --- cube (제작) ---
godot::Dictionary get_cube() const; // {open, npc_vnum, need_gold, recipes:[...]}
bool cube_make(int result_index);
bool cube_request_result_list(int npc_vnum);
bool cube_request_materials(int start_index, int count);
bool cube_open();
bool cube_close();
bool cube_list();
bool cube_add_item(int cube_index, int inventory_index);
bool cube_delete_item(int cube_index);
// --- guild ---
godot::Dictionary get_guild() const; // {in_guild, id, name, level, exp, gold, ...}
godot::Array get_guild_members() const; // [{pid, name, grade, job, level, offer, general}]
godot::Array get_guild_grades() const; // [{name, auth}] index = grade
bool guild_add_member(int vid);
bool guild_remove_member(int pid);
bool guild_offer(int amount);
bool guild_charge_gsp(int amount);
// 40250 服务器把这两个子头禁用了(input_main.cpp 直接 return,死代码在下方),
// 但原版 ClientVS22 UI 仍照发(uiguild.py OnDeposit/OnWithdraw)——保持 1:1。
bool guild_deposit_money(int amount);
bool guild_withdraw_money(int amount);
bool guild_change_grade_name(int grade, const godot::String &name);
bool guild_change_grade_authority(int grade, int authority);
bool guild_change_member_grade(int pid, int grade);
bool guild_change_member_general(int pid, bool enabled);
bool guild_post_comment(const godot::String &text);
bool guild_delete_comment(int comment_id);
bool guild_refresh_comments();
bool guild_answer_invite(int guild_id, bool accept);
bool guild_answer_make(const godot::String &name);
godot::Array get_guild_comments() const; // [{id, name, content}]
// --- guild war / guild skill ---
godot::Dictionary get_guild_skill() const; // {valid, skill_point, guild_point, max_guild_point, levels[12]}
godot::Array get_guild_wars() const; // [{src, dst, src_name, dst_name}] active GvG
godot::Dictionary get_guild_war() const; // {opp_guild_id, opp_name, type, state}
godot::String get_guild_name(int guild_id) const;
bool use_guild_skill(int skill_vnum, int target_vid);
bool declare_guild_war(const godot::String &guild_name); // sends "/war <name>"
// --- guild marks (会徽) ---
// Opens the side connection to `host:port` and pulls the mark images using
// the handle/random_key from login. `guild_marks_ready` fires when done.
// A port of 0 skips the query. Safe to call again on `guild_mark_updated`.
bool download_guild_marks(const godot::String &host, int port);
// Download one raw guild-symbol file via the mark side connection.
bool download_guild_symbol(const godot::String &host, int port, int guild_id);
// Raw bytes from the last completed download_guild_symbol call.
godot::PackedByteArray get_guild_symbol() const;
bool are_guild_marks_ready() const;
// {host, port} last used for a mark connection (0 port = none configured yet).
godot::Dictionary get_mark_server() const;
// Upload this guild's 16x12 mark (a godot::Image, converted/resized as needed).
// `guild_mark_uploaded(ok)` fires when the packet has left the socket.
bool upload_guild_mark(const godot::String &host, int port, int guild_id,
const godot::Ref<godot::Image> &img);
// Upload the raw bytes of a guild-symbol image file (server validates 64x128).
bool upload_guild_symbol(const godot::String &host, int port, int guild_id,
const godot::PackedByteArray &file_bytes);
// {found, img_idx, x, y, w, h} — position of a guild's 16x12 mark in its image.
godot::Dictionary get_guild_mark(int guild_id) const;
// A 16x12 RGBA8 godot::Image for a guild's mark, or an empty (null) Ref if we
// have no mark for it yet.
godot::Ref<godot::Image> get_guild_mark_image(int guild_id) const;
// --- refine / upgrade ---
bool refine(int pos, int type); // confirm refine of the item at inventory `pos`
// --- dragon soul refine ---
// mode: 0 = 升级 (upgrade), 1 = 改良 (improvement), 2 = 精炼 (refine).
// cells: inventory cells; cells[0] = the dragon soul, cells[1..] = materials (<=15 total).
bool ds_refine(int mode, const godot::Array &cells);
godot::Array get_dragon_souls() const; // [{cell, vnum, count}] non-empty DS-window slots
// --- P9 world systems ---
int get_channel() const;
int64_t get_server_time() const; // unix seconds as of last GC_TIME
godot::Array get_npc_marks() const; // [{type, vnum, name, pos}] pos = Godot metres
godot::Array get_land_areas() const; // [{id, guild_id, rect}] in server cm
godot::Array get_observers() const; // [{vid, pos}] in Godot metres
godot::Array get_world_markers() const; // [{id, name, vid, type, pos}]
bool is_observer_mode() const;
int get_observer_count() const;
bool has_mobile_flag() const;
bool combo_skill_enabled() const;
godot::Dictionary get_stamina_state() const; // {consuming, per_sec, current}
// items. window: 1=inventory, 2=equipment (mtnet::WINDOW_*).
bool move_item(int from_window, int from_cell, int to_window, int to_cell, int count);
bool use_item(int window, int cell);
bool drop_item(int window, int cell, int gold);
bool drop_item_count(int window, int cell, int gold, int count);
bool use_item_to_item(int source_window, int source_cell, int target_window, int target_cell);
bool give_item(int target_vid, int window, int cell, int count);
bool pickup_item(int ground_vid);
godot::Array get_inventory() const; // non-empty slots
godot::Array get_equipment() const; // WEAR_MAX_NUM slots (may be empty)
godot::Array get_belt_inventory() const; // non-empty 4x4 belt slots
godot::Array get_view_equipment(int vid) const; // 11 legacy inspect slots (may be empty)
godot::Dictionary get_item(int window, int cell) const;
godot::Array get_ground_items() const; // {vid, vnum, pos}
godot::Array get_pvp_relations() const; // [{src_vid, dst_vid, mode}]
godot::Dictionary get_duel() const; // {active, cannot_attack, opponents}
// --- networked world snapshot (positions already Godot-space, metres) ---
godot::Dictionary get_entity(int vid) const;
// Root-motion speeds (cm/s at movSpd 100) of a remote actor's WALK / RUN loop,
// measured by the view from its .msa; drives the reference-style remote walk.
void set_entity_motion_speed(int vid, double walk_cm_s, double run_cm_s);
// Presentation-owned CInstanceBase network-state gates. These are updated
// when a remote skill/emotion starts or reaches its motion tail.
void set_entity_network_state_gate(int vid, bool skill_active, bool skill_can_cancel,
bool acting_emotion, bool tcp_state_enabled);
// Presentation-owned knockdown latch used by TEMP_Push's
// DAMAGE_FLYING -> STAND_UP motion chain.
void set_entity_knockdown(int vid, bool on);
godot::Array get_entities() const;
int get_main_vid() const;
int get_main_pid() const;
godot::Dictionary get_points() const; // local player stat block
godot::Dictionary get_target() const; // {vid, hp_percent} of selected target
godot::Array get_affects() const; // active buffs/debuffs on the local player
// App lifecycle (F5). suspend() stops pumping the socket (called
// automatically on NOTIFICATION_APPLICATION_PAUSED); resume() re-enables it.
// A mobile OS tears the TCP connection down within seconds of backgrounding,
// so the first pump after resume typically surfaces `disconnected` — call
// reconnect() to redo the login with the stored credentials.
void suspend();
void resume();
bool reconnect();
bool is_suspended() const { return suspended; }
godot::String get_stage() const { return stage_name(); }
bool is_in_game() const;
// Drain the socket + answer PING once, WITHOUT emitting the per-frame world
// signals. Call this from GDScript around any blocking work (model loads,
// map build) so the server doesn't drop us for going silent.
void net_poll();
protected:
static void _bind_methods();
private:
enum class Stage { Idle, AuthConnect, AuthWait, GameConnect, GameLogin, InGame, Failed };
Stage stage = Stage::Idle;
godot::String stage_name() const;
void set_stage(Stage s);
godot::String game_host;
int game_port = 0;
godot::String account_id;
// Stored only until the classic session has sent its login packet. The
// reference client clears its network password at that boundary; classic
// reconnects then use last_login_key or require a fresh explicit login.
godot::String cfg_auth_host, cfg_game_host, cfg_id, cfg_pw;
int cfg_auth_port = 0, cfg_game_port = 0;
bool have_cfg = false;
bool suspended = false;
std::unique_ptr<mtnet::AuthClient> auth;
std::unique_ptr<mtnet::GameClient> game;
// MT_PROTOCOL=classic (40250): replaces auth+game for the flows it supports
// (connect/login/select/enter-game/world/intents). Null otherwise.
std::unique_ptr<mtnet::classic::ClassicSession> classic_sess;
bool classic_list_emitted = false;
// A peer-close or replacement-connect failure has already returned
// ClassicSession to LoginPhase, but the host must still allow the next
// reconnect request to replace the old socket instead of treating the
// transient GameLogin stage as in-flight.
bool classic_remote_disconnect_pending = false;
// Keep the auth Offline owner retryable across pumps after
// AccountConnector::OnConnectFailure; a later frame must not synthesize a
// terminal Failed state for the same transport error.
bool classic_auth_connect_failure_pending = false;
// AccountConnector::OnRemoteDisconnect returns to Offline without invoking
// OnLoginFailure/OnConnectFailure; keep that owner non-terminal across pumps.
bool classic_auth_remote_disconnect_pending = false;
int classic_last_stage = -1;
int classic_last_empire = -1;
void pump_classic();
void bind_classic_loading_reset();
// the EntityStore of whichever backend is active (classic_sess or game).
const mtnet::EntityStore *active_world() const;
std::unique_ptr<mtnet::MarkClient> mark;
std::unique_ptr<mtnet::classic::ClassicMarkClient> classic_mark;
std::unique_ptr<mtnet::MarkImageSet> mark_store; // survives after `mark` is torn down
godot::String mark_host;
int mark_port = 0;
bool mark_ready = false;
uint32_t symbol_guild_id = 0;
std::vector<uint8_t> symbol_data;
void pump_mark();
void pump_classic_mark();
int last_auth_state = -1;
int last_game_state = -1;
int last_game_phase = -1;
int last_game_empire = -1;
bool char_list_emitted = false;
uint32_t selected_pid = 0;
godot::Array build_char_list() const;
// CG_ENTERGAME is sent ~1.5s into PHASE_LOADING (not immediately) — sending
// it before the server finishes the spawn burst makes it drop us ~10s later.
uint64_t loading_since_ms = 0;
bool enter_game_sent = false;
bool shop_open_seen = false; // last shop_open() state we emitted a signal for
bool mall_open_seen = false;
bool safebox_open_seen = false;
uint32_t last_login_key = 0; // for a cross-server GC_WARP reconnect
void warp_to_game_server(const godot::String &host, int port);
std::unordered_set<uint32_t> dead_seen; // vids we've already fired entity_dead for
void pump_auth();
void pump_game();
bool item_action_allowed(bool equipment_touched) const;
bool is_equip_item_in_slot(int window, int cell) const;
bool is_equipment_cell(int window, int cell) const;
bool item_proto_known(uint32_t vnum) const;
bool item_proto_type(uint32_t vnum, uint8_t &type) const;
void emit_item_action_sound(int window, int cell, bool drop);
void apply_item_proto_validator();
godot::ObjectID item_proto_id;
bool item_proto_ready = false;
mtnet::EntityStore *item_validator_world = nullptr;
bool item_validator_ready = false;
bool local_main_attacking = false;
};
} // namespace mtgodot
+2 -28
View File
@@ -7,29 +7,18 @@
// with the Chinese build using GB2312-compatible bytes) and fills fixed C
// 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 <godot_cpp/...> is on the
// include path (the M2Client / GDScript boundary).
// ClassicParser and ClassicSession use the std::string_view API below.
#include <cstddef>
#include <cstdint>
#include <string>
#include <string_view>
#if defined(__has_include)
# if __has_include(<godot_cpp/variant/string.hpp>)
# include <godot_cpp/variant/char_string.hpp>
# include <godot_cpp/variant/string.hpp>
# define MT_TEXT_CODEC_HAS_GODOT 1
# endif
#endif
namespace mtnet {
@@ -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<size_t>(u.length()) : 0),
cap);
}
inline godot::String from_wire(const char *bytes, size_t cap) {
const std::string s = from_wire_str(bytes, cap);
return godot::String::utf8(s.c_str(), static_cast<int64_t>(s.size()));
}
#endif
} // namespace mtnet
-35
View File
@@ -1,35 +0,0 @@
#include "pack40250_node.h"
#include <godot_cpp/core/class_db.hpp>
#include "asset_io.h"
#include "platform/PackBackend.h"
using namespace godot;
namespace mtgodot {
bool Metin2Pack::initialize(const String &client_dir) {
return mtpack40250::initialize(client_dir.utf8().get_data());
}
bool Metin2Pack::is_ready() {
return mtpack40250::ready();
}
bool Metin2Pack::exists(const String &name) {
return file_exists(String(PACK_SCHEME) + name);
}
PackedByteArray Metin2Pack::get_bytes(const String &name) {
return read_file(String(PACK_SCHEME) + name);
}
void Metin2Pack::_bind_methods() {
ClassDB::bind_static_method("Metin2Pack", D_METHOD("initialize", "client_dir"), &Metin2Pack::initialize);
ClassDB::bind_static_method("Metin2Pack", D_METHOD("is_ready"), &Metin2Pack::is_ready);
ClassDB::bind_static_method("Metin2Pack", D_METHOD("exists", "name"), &Metin2Pack::exists);
ClassDB::bind_static_method("Metin2Pack", D_METHOD("get_bytes", "name"), &Metin2Pack::get_bytes);
}
} // namespace mtgodot
-28
View File
@@ -1,28 +0,0 @@
#pragma once
// Metin2Pack — GDScript access to the 40250 packs (platform/PackBackend.h).
//
// Metin2Pack.initialize(AssetRoot.client_path()) # registers <Client>/pack/Index once
// var b := Metin2Pack.get_bytes("locale/en/item_proto")
//
// Every native reader that takes a path accepts "pack://<40250 path>" through asset_io.
#include <godot_cpp/classes/object.hpp>
#include <godot_cpp/variant/packed_byte_array.hpp>
#include <godot_cpp/variant/string.hpp>
namespace mtgodot {
class Metin2Pack : public godot::Object {
GDCLASS(Metin2Pack, godot::Object)
public:
static bool initialize(const godot::String &client_dir);
static bool is_ready();
static bool exists(const godot::String &name);
static godot::PackedByteArray get_bytes(const godot::String &name);
protected:
static void _bind_methods();
};
} // namespace mtgodot
@@ -8,6 +8,7 @@
#include "EterBase/Timer.h"
#include "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<DWORD>(localSlotIndex));
}
bool TryAssignAttachedObjectToLocalQuickSlot(int localSlotIndex)
{
if (!g_game_singletons || localSlotIndex < 0 || localSlotIndex >= 8) return false;
std::string attached;
std::string error;
if (!Evaluate("__import__('mouseModule').mouseController.isAttached()", &attached, &error) ||
(attached != "True" && attached != "1"))
return false;
const std::string command =
"_stream.curPhaseWindow.interface.wndTaskBar.AddQuickSlot(" + std::to_string(localSlotIndex) + ")";
return RunLine(command.c_str(), &error);
}
std::string LocalQuickSlotSkillIcon(int localSlotIndex)
{
if (!g_game_singletons || localSlotIndex < 0 || localSlotIndex >= 8) return {};
DWORD type = SLOT_TYPE_NONE;
DWORD position = 0;
CPythonPlayer& player = CPythonPlayer::Instance();
player.GetLocalQuickSlotData(static_cast<DWORD>(localSlotIndex), &type, &position);
if (type != SLOT_TYPE_SKILL) return {};
CPythonSkill::TSkillData* skillData = nullptr;
if (!CPythonSkill::Instance().GetSkillData(player.GetSkillIndex(position), &skillData) || !skillData)
return {};
const int grade = std::clamp(player.GetSkillGrade(position), 0, CPythonSkill::SKILL_GRADE_COUNT - 1);
CGraphicImage* image = skillData->GradeData[grade].pImage;
if (!image) image = skillData->pImage;
return image ? image->GetFileName() : std::string{};
}
void CameraBeginDrag(int x, int y)
{
CCameraManager& rkCmrMgr = CCameraManager::Instance();
@@ -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();
+3 -6
View File
@@ -1,6 +1,5 @@
# port_logic — mirror of the 40250 client logic (extension/src/port/<Lib>/<File>.{h,cpp}).
# No godot-cpp dependency: libmtgodot links it, and it also builds on its own (top-level
# -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: <lzo/lzo1x.h> (EterBase/lzo.h), <cryptopp/*>
# (EterBase/cipher.h), <Python-2.7/*> (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)
+38
View File
@@ -33,6 +33,32 @@
#include <iostream>
#include <fstream>
#if !defined(_WIN32)
#include <dirent.h> // PORT: resolve separately cased .eix/.epk names on case-sensitive mobile filesystems
#include <strings.h> // PORT: strcasecmp for the on-disk pack name lookup
// PORT: 40250 ships pairs such as ETC.eix and Etc.epk. Windows opens both through the
// same stem; Android must locate each file using its actual on-disk spelling.
static std::string ResolvePackFileName(const std::string& requested)
{
const std::string::size_type slash = requested.find_last_of('/');
const std::string folder = slash == std::string::npos ? "." : requested.substr(0, slash);
const std::string name = requested.substr(slash == std::string::npos ? 0 : slash + 1);
DIR* dir = opendir(folder.c_str());
if (!dir) return requested;
std::string result = requested;
while (dirent* entry = readdir(dir))
{
if (strcasecmp(entry->d_name, name.c_str()) == 0)
{
result = folder + "/" + entry->d_name;
break;
}
}
closedir(dir);
return result;
}
#endif
void CMakePackLog::SetFileName(const char* c_szFileName)
{
@@ -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;
@@ -1,4 +1,6 @@
#include "StdAfx.h"
#include <algorithm>
#include <cctype>
#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();
}
@@ -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
{
@@ -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 },
-164
View File
@@ -1,164 +0,0 @@
#include "proto_node.h"
#include "../asset_io.h"
#include <godot_cpp/core/class_db.hpp>
#include <string>
using namespace godot;
namespace mtgodot {
namespace {
// szName is the Korean (CP949) development name; what 40250 shows is szLocaleName (CItemData::GetName,
// CPythonNonPlayer::GetMonsterName) in the locale code page, 1252 for locale.cfg "10002 1252 en".
String from_cp1252(const std::string &s) {
static const char32_t k80[32] = {
0x20AC, 0xFFFD, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0x0160, 0x2039, 0x0152, 0xFFFD, 0x017D, 0xFFFD,
0xFFFD, 0x2018, 0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0x02DC, 0x2122, 0x0161, 0x203A, 0x0153, 0xFFFD, 0x017E, 0x0178,
};
String out;
for (unsigned char c : s) {
out += String::chr(c >= 0x80 && c < 0xA0 ? k80[c - 0x80] : static_cast<char32_t>(c));
}
return out;
}
} // namespace
void Metin2Proto::_bind_methods() {
ClassDB::bind_method(D_METHOD("load_item_proto", "path"), &Metin2Proto::load_item_proto);
ClassDB::bind_method(D_METHOD("load_mob_proto", "path"), &Metin2Proto::load_mob_proto);
ClassDB::bind_method(D_METHOD("item", "vnum"), &Metin2Proto::item);
ClassDB::bind_method(D_METHOD("mob", "vnum"), &Metin2Proto::mob);
ClassDB::bind_method(D_METHOD("item_count"), &Metin2Proto::item_count);
ClassDB::bind_method(D_METHOD("mob_count"), &Metin2Proto::mob_count);
ClassDB::bind_method(D_METHOD("get_last_error"), &Metin2Proto::get_last_error);
}
bool Metin2Proto::load_item_proto(const String &path) {
std::string err;
PackedByteArray bytes = mtgodot::read_file(path);
std::vector<uint8_t> buf(bytes.ptr(), bytes.ptr() + bytes.size());
if (!mtproto::load_proto_bytes(buf, mtproto::ITEM_PROTO_KEY, m_item, &err)) {
last_error = String(err.c_str());
return false;
}
m_item_ix.clear();
m_item_ix.reserve(m_item.elements);
for (uint32_t i = 0; i < m_item.elements; ++i) {
mtproto::ItemRecord r = mtproto::parse_item(m_item.record(i), m_item.stride);
m_item_ix[r.vnum] = i;
}
last_error = "";
return true;
}
bool Metin2Proto::load_mob_proto(const String &path) {
std::string err;
PackedByteArray bytes = mtgodot::read_file(path);
std::vector<uint8_t> buf(bytes.ptr(), bytes.ptr() + bytes.size());
if (!mtproto::load_proto_bytes(buf, mtproto::MOB_PROTO_KEY, m_mob, &err)) {
last_error = String(err.c_str());
return false;
}
m_mob_ix.clear();
m_mob_ix.reserve(m_mob.elements);
for (uint32_t i = 0; i < m_mob.elements; ++i) {
mtproto::MobRecord r = mtproto::parse_mob(m_mob.record(i), m_mob.stride);
m_mob_ix[r.vnum] = i;
}
last_error = "";
return true;
}
Dictionary Metin2Proto::item(int vnum) const {
Dictionary d;
auto it = m_item_ix.find((uint32_t)vnum);
if (it == m_item_ix.end() || m_item.record(it->second) == nullptr) {
return d;
}
mtproto::ItemRecord r = mtproto::parse_item(m_item.record(it->second), m_item.stride);
d["vnum"] = (int)r.vnum;
d["vnum_range"] = (int)r.vnum_range;
d["name"] = from_cp1252(r.locale_name); // CItemData::GetName()
d["locale_name"] = from_cp1252(r.locale_name);
d["type"] = (int)r.type;
d["sub_type"] = (int)r.sub_type;
d["weight"] = (int)r.weight;
d["size"] = (int)r.size;
d["anti_flags"] = (int)r.anti_flags;
d["flags"] = (int)r.flags;
d["wear_flags"] = (int)r.wear_flags;
d["immune_flag"] = (int)r.immune_flag;
d["buy_price"] = (int)r.buy_price;
d["sell_price"] = (int)r.sell_price;
{
Array limits;
for (const mtproto::ItemLimit &limit : r.limits) {
Dictionary entry;
entry["type"] = (int)limit.type;
entry["value"] = (int)limit.value;
limits.push_back(entry);
}
d["limits"] = limits;
}
{
Array applies;
for (const mtproto::ItemApply &apply : r.applies) {
Dictionary entry;
entry["type"] = (int)apply.type;
entry["value"] = (int)apply.value;
applies.push_back(entry);
}
d["applies"] = applies;
}
{
Array vals;
for (int i = 0; i < 6; ++i) {
vals.push_back((int)r.values[i]);
}
d["values"] = vals; // armor: values[3] = body shape index for the race .msm
}
{
Array sockets;
for (int socket : r.sockets) {
sockets.push_back(socket);
}
d["sockets"] = sockets;
}
d["refined_vnum"] = (int)r.refined_vnum;
d["refine_set"] = (int)r.refine_set;
d["alter_to_magic_pct"] = (int)r.alter_to_magic_pct;
d["specular"] = (int)r.specular;
d["gain_socket_pct"] = (int)r.gain_socket_pct;
return d;
}
Dictionary Metin2Proto::mob(int vnum) const {
Dictionary d;
auto it = m_mob_ix.find((uint32_t)vnum);
if (it == m_mob_ix.end() || m_mob.record(it->second) == nullptr) {
return d;
}
mtproto::MobRecord r = mtproto::parse_mob(m_mob.record(it->second), m_mob.stride);
d["vnum"] = (int)r.vnum;
d["name"] = from_cp1252(r.locale_name); // CPythonNonPlayer::GetMonsterName()
d["locale_name"] = from_cp1252(r.locale_name);
d["type"] = (int)r.type;
d["rank"] = (int)r.rank;
d["battle_type"] = (int)r.battle_type;
d["level"] = (int)r.level;
d["size"] = (int)r.size;
d["attack_speed"] = (int)r.attack_speed;
d["moving_speed"] = (int)r.moving_speed;
d["on_click_type"] = (int)r.on_click_type;
d["empire"] = (int)r.empire;
d["folder"] = String(r.folder.c_str());
d["monster_color"] = (int64_t)r.monster_color;
return d;
}
} // namespace mtgodot
-48
View File
@@ -1,48 +0,0 @@
#pragma once
// Metin2Proto — GDExtension node exposing item_proto / mob_proto to GDScript.
//
// var proto = Metin2Proto.new()
// proto.load_item_proto("pack://locale/en/item_proto") # 40250 format, via asset_io
// var d := proto.item(19) # { vnum, name, locale_name, type, sub_type, ... }
//
// Used by the P2 inventory/equipment windows for names / types / tooltips and by
// the equip->model path.
#include <godot_cpp/classes/node.hpp>
#include <godot_cpp/variant/array.hpp>
#include <godot_cpp/variant/dictionary.hpp>
#include <godot_cpp/variant/string.hpp>
#include <cstdint>
#include <unordered_map>
#include "proto.h"
namespace mtgodot {
class Metin2Proto : public godot::Node {
GDCLASS(Metin2Proto, godot::Node)
public:
bool load_item_proto(const godot::String &path);
bool load_mob_proto(const godot::String &path);
godot::Dictionary item(int vnum) const;
godot::Dictionary mob(int vnum) const;
int item_count() const { return (int)m_item.elements; }
int mob_count() const { return (int)m_mob.elements; }
godot::String get_last_error() const { return last_error; }
protected:
static void _bind_methods();
private:
mtproto::Proto m_item;
mtproto::Proto m_mob;
std::unordered_map<uint32_t, uint32_t> m_item_ix; // vnum -> record index
std::unordered_map<uint32_t, uint32_t> m_mob_ix;
godot::String last_error;
};
} // namespace mtgodot
-669
View File
@@ -1,669 +0,0 @@
#include "python_host_node.h"
#include <godot_cpp/core/class_db.hpp>
#include <godot_cpp/variant/utility_functions.hpp>
#include "python_stdlib.h"
#ifdef MTGODOT_HAVE_PYTHON
// Standard types only — PythonBoot.h keeps <Python-2.7/*> and the Win32 shims out of this translation
// unit, which has to stay compilable next to godot-cpp.
#include "platform/ScriptLib/PythonBoot.h"
#include "platform/EterLib/UIRenderCommands.h"
#include "platform/EterLib/RenderCommands3D.h"
#include "platform/MilesLib/AudioCommands.h"
#include "platform/PackBackend.h"
#include "../../native_render/draw_capture.h"
#include <cstdlib>
#include <cstring>
#include <string>
#include <type_traits>
#include <unordered_map>
#endif
#include <godot_cpp/classes/image.hpp>
#include <godot_cpp/variant/dictionary.hpp>
#include <godot_cpp/variant/packed_byte_array.hpp>
#include <godot_cpp/variant/packed_color_array.hpp>
#include <godot_cpp/variant/packed_float32_array.hpp>
#include <godot_cpp/variant/packed_int32_array.hpp>
#include <godot_cpp/variant/packed_vector2_array.hpp>
#include <godot_cpp/variant/packed_vector3_array.hpp>
#include <godot_cpp/variant/string_name.hpp>
using namespace godot;
namespace mtgodot {
#ifdef MTGODOT_HAVE_PYTHON
String Metin2PythonHost::start(const String &stdlib_path) {
String path = stdlib_path;
if (path.is_empty()) {
path = Metin2Python::stdlib_path(false);
if (path.is_empty()) {
return String("python27.zip unavailable: ") + Metin2Python::last_error();
}
}
std::string error;
if (!PythonBoot::Start(path.utf8().get_data(), &error)) {
return String::utf8(error.c_str());
}
return String();
}
bool Metin2PythonHost::is_running() {
return PythonBoot::IsRunning();
}
String Metin2PythonHost::run_main_script(const String &command_line) {
std::string error;
if (!PythonBoot::RunMainScript(command_line.utf8().get_data(), &error)) {
return String::utf8(error.c_str());
}
return String();
}
String Metin2PythonHost::run_line(const String &source) {
std::string error;
if (!PythonBoot::RunLine(source.utf8().get_data(), &error)) {
return String::utf8(error.c_str());
}
return String();
}
// str(expression) from __main__, or "" when it raised (the error goes to the log like run_line's).
String Metin2PythonHost::evaluate(const String &expression) {
std::string result, error;
if (!PythonBoot::Evaluate(expression.utf8().get_data(), &result, &error)) {
UtilityFunctions::push_error(String::utf8(error.c_str()));
return String();
}
return String::utf8(result.c_str());
}
bool Metin2PythonHost::is_app_looping() { return PythonBoot::IsAppLooping(); }
void Metin2PythonHost::set_ui_size(int width, int height) { PythonBoot::SetUISize(width, height); }
void Metin2PythonHost::ui_mouse_move(int x, int y) { PythonBoot::UIMouseMove(x, y); }
void Metin2PythonHost::ui_mouse_button(int button, bool pressed, int x, int y) {
PythonBoot::UIMouseButton(button, pressed, x, y);
}
void Metin2PythonHost::ui_mouse_wheel(int delta) { PythonBoot::UIMouseWheel(delta); }
void Metin2PythonHost::ui_key(int key, bool pressed) { PythonBoot::UIKey(key, pressed); }
void Metin2PythonHost::ui_char(int codepoint) { PythonBoot::UIChar(unsigned(codepoint)); }
void Metin2PythonHost::ui_ime_key(int vkey) { PythonBoot::UIIMEKeyDown(vkey); }
void Metin2PythonHost::ui_update() { PythonBoot::UIUpdate(); }
namespace {
Dictionary ui_command_item(const UIRenderCommand &command) {
static const StringName s_kind("kind");
static const StringName s_x1("x1");
static const StringName s_y1("y1");
static const StringName s_x2("x2");
static const StringName s_y2("y2");
static const StringName s_argb("argb");
static const StringName s_end_argb("end_argb");
static const StringName s_clip_x1("clip_x1");
static const StringName s_clip_y1("clip_y1");
static const StringName s_clip_x2("clip_x2");
static const StringName s_clip_y2("clip_y2");
static const StringName s_text("text");
static const StringName s_quad("quad");
static const StringName s_uv("uv");
static const StringName s_blend("blend");
static const StringName s_mask("mask");
static const StringName s_mask_uv("mask_uv");
static const StringName s_behind_3d("behind_3d");
static const StringName s_val_bar("bar");
static const StringName s_val_gradient_bar("gradient_bar");
static const StringName s_val_line("line");
static const StringName s_val_image("image");
static const StringName s_val_text("text");
Dictionary item;
item[s_kind] = command.kind == UIRenderCommand::GradientBar ? s_val_gradient_bar :
(command.kind == UIRenderCommand::Bar ? s_val_bar :
(command.kind == UIRenderCommand::Line ? s_val_line :
(command.kind == UIRenderCommand::Image ? s_val_image : s_val_text)));
item[s_x1] = command.x1;
item[s_y1] = command.y1;
item[s_x2] = command.x2;
item[s_y2] = command.y2;
item[s_argb] = static_cast<int64_t>(command.argb);
if (command.kind == UIRenderCommand::GradientBar)
item[s_end_argb] = static_cast<int64_t>(command.end_argb);
item[s_clip_x1] = command.clip_x1;
item[s_clip_y1] = command.clip_y1;
item[s_clip_x2] = command.clip_x2;
item[s_clip_y2] = command.clip_y2;
if (command.kind == UIRenderCommand::Text || command.kind == UIRenderCommand::Image)
item[s_text] = String::utf8(command.text.c_str());
if (command.quad) {
PackedVector2Array quad;
quad.resize(4);
Vector2 *qptr = quad.ptrw();
for (int i = 0; i < 4; ++i)
qptr[i] = Vector2(command.qx[i], command.qy[i]);
item[s_quad] = quad;
PackedVector2Array uv;
uv.resize(4);
Vector2 *uvptr = uv.ptrw();
uvptr[0] = Vector2(command.su, command.sv);
uvptr[1] = Vector2(command.eu, command.sv);
uvptr[2] = Vector2(command.su, command.ev);
uvptr[3] = Vector2(command.eu, command.ev);
item[s_uv] = uv;
item[s_blend] = command.blend;
if (!command.mask.empty()) {
item[s_mask] = String::utf8(command.mask.c_str());
PackedVector2Array mask_uv;
mask_uv.resize(4);
Vector2 *mptr = mask_uv.ptrw();
for (int i = 0; i < 4; ++i)
mptr[i] = Vector2(command.mu[i], command.mv[i]);
item[s_mask_uv] = mask_uv;
}
}
item[s_behind_3d] = command.behind_3d;
return item;
}
} // namespace
Array Metin2PythonHost::ui_render_commands() {
PythonBoot::UIRender();
static std::uint64_t cached_frame_id = UINT64_MAX;
static Array cached_commands;
const std::uint64_t frame_id = UIRenderFrameId();
if (cached_frame_id == frame_id)
return cached_commands;
const auto &commands = UIRenderCommands();
Array out;
out.resize(static_cast<int64_t>(commands.size()));
for (size_t idx = 0; idx < commands.size(); ++idx)
out[static_cast<int64_t>(idx)] = ui_command_item(commands[idx]);
cached_frame_id = frame_id;
cached_commands = out;
return out;
}
Array Metin2PythonHost::ui_render_commands_batched() {
PythonBoot::UIRender();
static std::uint64_t cached_frame_id = UINT64_MAX;
static Array cached_commands;
const std::uint64_t frame_id = UIRenderFrameId();
if (cached_frame_id == frame_id)
return cached_commands;
const auto &commands = UIRenderCommands();
const auto batchable = [](const UIRenderCommand &c) {
return c.kind == UIRenderCommand::Image && c.quad && c.text.rfind("mem:", 0) == 0 &&
c.mask.empty() && c.blend == 0 && c.x1 >= c.clip_x1 && c.y1 >= c.clip_y1 &&
c.x2 <= c.clip_x2 && c.y2 <= c.clip_y2;
};
Array out;
for (size_t i = 0; i < commands.size();) {
const UIRenderCommand &first = commands[i];
if (!batchable(first)) {
out.push_back(ui_command_item(first));
++i;
continue;
}
size_t end = i + 1;
while (end < commands.size() && batchable(commands[end]) &&
commands[end].text == first.text && commands[end].behind_3d == first.behind_3d)
++end;
if (end == i + 1) {
out.push_back(ui_command_item(first));
i = end;
continue;
}
const int64_t count = static_cast<int64_t>(end - i);
PackedVector2Array points, uvs;
PackedColorArray colors;
PackedInt32Array indices;
points.resize(count * 4);
uvs.resize(count * 4);
colors.resize(count * 4);
indices.resize(count * 6);
Vector2 *point_data = points.ptrw(), *uv_data = uvs.ptrw();
Color *color_data = colors.ptrw();
int32_t *index_data = indices.ptrw();
for (int64_t j = 0; j < count; ++j) {
const UIRenderCommand &c = commands[i + static_cast<size_t>(j)];
const int64_t v = j * 4, t = j * 6;
point_data[v] = Vector2(c.qx[0], c.qy[0]);
point_data[v + 1] = Vector2(c.qx[1], c.qy[1]);
point_data[v + 2] = Vector2(c.qx[3], c.qy[3]);
point_data[v + 3] = Vector2(c.qx[2], c.qy[2]);
uv_data[v] = Vector2(c.su, c.sv);
uv_data[v + 1] = Vector2(c.eu, c.sv);
uv_data[v + 2] = Vector2(c.eu, c.ev);
uv_data[v + 3] = Vector2(c.su, c.ev);
const Color color(((c.argb >> 16) & 255) / 255.0f, ((c.argb >> 8) & 255) / 255.0f,
(c.argb & 255) / 255.0f, ((c.argb >> 24) & 255) / 255.0f);
for (int k = 0; k < 4; ++k)
color_data[v + k] = color;
const int32_t base = static_cast<int32_t>(v);
index_data[t] = base;
index_data[t + 1] = base + 1;
index_data[t + 2] = base + 2;
index_data[t + 3] = base;
index_data[t + 4] = base + 2;
index_data[t + 5] = base + 3;
}
Dictionary item;
item["kind"] = StringName("glyph_batch");
item["text"] = String::utf8(first.text.c_str());
item["behind_3d"] = first.behind_3d;
item["points"] = points;
item["uvs"] = uvs;
item["colors"] = colors;
item["indices"] = indices;
item["glyph_count"] = count;
out.push_back(item);
i = end;
}
cached_frame_id = frame_id;
cached_commands = out;
return out;
}
bool Metin2PythonHost::has_3d_draws() { return !Render3DDraws().empty(); }
Ref<Image> Metin2PythonHost::memory_texture(const String &name, int64_t known_revision) {
UIMemoryTexture texture;
if (!UIRenderMemoryTexture(name.utf8().get_data(), &texture) || texture.width <= 0 || texture.height <= 0)
return Ref<Image>();
if (known_revision >= 0 && texture.revision == static_cast<std::uint32_t>(known_revision))
return Ref<Image>();
PackedByteArray rgba;
rgba.resize(static_cast<int64_t>(texture.argb.size()) * 4);
uint8_t *out = rgba.ptrw();
for (std::uint32_t argb : texture.argb) {
*out++ = static_cast<uint8_t>(argb >> 16);
*out++ = static_cast<uint8_t>(argb >> 8);
*out++ = static_cast<uint8_t>(argb);
*out++ = static_cast<uint8_t>(argb >> 24);
}
return Image::create_from_data(texture.width, texture.height, false, Image::FORMAT_RGBA8, rgba);
}
namespace {
PackedFloat32Array floats(const float *values, int count) {
PackedFloat32Array out;
out.resize(count);
for (int i = 0; i < count; ++i)
out[i] = values[i];
return out;
}
Color argb_color(std::uint32_t argb) {
return Color(((argb >> 16) & 255) / 255.0f, ((argb >> 8) & 255) / 255.0f, (argb & 255) / 255.0f,
((argb >> 24) & 255) / 255.0f);
}
bool read_pack_texture(const std::string &vpath, std::vector<std::uint8_t> &bytes) {
if (vpath.empty() || !mtpack40250::ready())
return false;
std::string norm = vpath;
for (char &ch : norm)
if (ch == '\\')
ch = '/';
std::string stripped = norm;
if (stripped.size() >= 2 && stripped[1] == ':')
stripped = stripped.substr(2);
while (!stripped.empty() && stripped.front() == '/')
stripped.erase(stripped.begin());
auto lower = [](std::string s) {
for (char &ch : s)
if (ch >= 'A' && ch <= 'Z')
ch = static_cast<char>(ch - 'A' + 'a');
return s;
};
for (const std::string &candidate : {
norm, stripped, "d:/" + stripped,
lower(norm), lower(stripped), lower("d:/" + stripped)}) {
if (mtpack40250::read(candidate, bytes) && !bytes.empty())
return true;
}
return false;
}
} // namespace
Array Metin2PythonHost::render3d_draws() {
struct CachedGeometry {
std::uint64_t revision;
std::uint64_t last_used;
Dictionary arrays;
};
static std::unordered_map<std::uint64_t, CachedGeometry> geometry_cache;
static std::uint64_t extraction = 0;
++extraction;
const auto &draws = Render3DDraws();
static bool capture_written = false;
if (!capture_written) {
const char *capture_path = std::getenv("MT_NATIVE_CAPTURE_PATH");
const char *minimum_text = std::getenv("MT_NATIVE_CAPTURE_MIN_DRAWS");
const unsigned long minimum = minimum_text ? std::strtoul(minimum_text, nullptr, 10) : 100UL;
if (capture_path && *capture_path && draws.size() >= minimum) {
capture_written = true;
try {
std::unordered_map<std::string, std::vector<std::uint8_t>> textures;
auto add_texture = [&](const std::string &name) {
if (name.empty() || textures.count(name))
return;
if (name.rfind("mem:", 0) == 0) {
UIMemoryTexture mem_tex;
if (UIRenderMemoryTexture(name, &mem_tex) && mem_tex.width > 0 && mem_tex.height > 0) {
auto mtra = native_draw_capture::encode_raw_argb_as_mtra(
static_cast<std::uint32_t>(mem_tex.width),
static_cast<std::uint32_t>(mem_tex.height),
mem_tex.argb.data());
if (!mtra.empty())
textures.emplace(name, std::move(mtra));
}
return;
}
std::vector<std::uint8_t> bytes;
if (read_pack_texture(name, bytes))
textures.emplace(name, std::move(bytes));
};
for (const Render3DDraw &draw : draws) {
add_texture(draw.texture0);
add_texture(draw.texture1);
}
const auto &ui_commands = UIRenderCommands();
for (const UIRenderCommand &cmd : ui_commands) {
if (cmd.kind == UIRenderCommand::Image) {
add_texture(cmd.text);
add_texture(cmd.mask);
}
}
unsigned ui_w = 0, ui_h = 0;
UIRenderGetSize(&ui_w, &ui_h);
native_draw_capture::write(
capture_path, draws, textures,
ui_w ? ui_w : 960u, ui_h ? ui_h : 640u, ui_commands);
UtilityFunctions::print(
"native draw capture: ", capture_path,
" draws=", static_cast<int64_t>(draws.size()),
" ui_commands=", static_cast<int64_t>(ui_commands.size()),
" textures=", static_cast<int64_t>(textures.size()));
} catch (const std::exception &error) {
UtilityFunctions::printerr("native draw capture failed: ", error.what());
}
}
}
Array out;
out.resize(static_cast<int64_t>(draws.size()));
int64_t draw_index = 0;
for (const Render3DDraw &draw : draws) {
if (draw.clear_flags)
continue; // back-buffer clears are consumed by the native renderer only
Dictionary item;
item["geometry_key"] = static_cast<int64_t>(draw.geometry_key);
item["geometry_revision"] = static_cast<int64_t>(draw.geometry_revision);
item["world"] = floats(draw.world, 16);
item["view"] = floats(draw.view, 16);
item["proj"] = floats(draw.proj, 16);
item["texture0"] = String::utf8(draw.texture0.c_str());
item["texture1"] = String::utf8(draw.texture1.c_str());
item["pretransformed"] = draw.pretransformed;
item["lines"] = draw.lines;
// Stage-0 coordinates the D3D8 pipeline generates or transforms depend on per-draw matrices,
// so such draws carry them outside the geometry cache.
const bool stage0_texgen = (draw.texcoord_index[0] & 0xFFFF0000u) != 0 ||
(draw.texcoord_index[0] & 0xFFFFu) != 0 || draw.texture_transform_flags[0] != 0;
if (stage0_texgen)
item["geometry_key"] = static_cast<int64_t>(0);
Dictionary geometry;
if (draw.geometry_key != 0 && !stage0_texgen) {
auto cached = geometry_cache.find(draw.geometry_key);
if (cached != geometry_cache.end() && cached->second.revision == draw.geometry_revision) {
cached->second.last_used = extraction;
geometry = cached->second.arrays;
}
}
if (geometry.is_empty()) {
PackedVector3Array positions;
positions.resize(static_cast<int64_t>(draw.positions.size() / 3));
if constexpr (sizeof(Vector3) == sizeof(float) * 3 && std::is_trivially_copyable_v<Vector3>) {
if (!draw.positions.empty())
std::memcpy(positions.ptrw(), draw.positions.data(), draw.positions.size() * sizeof(float));
} else {
Vector3 *dst = positions.ptrw();
for (int64_t i = 0; i < positions.size(); ++i)
dst[i] = Vector3(draw.positions[i * 3], draw.positions[i * 3 + 1], draw.positions[i * 3 + 2]);
}
geometry["positions"] = positions;
if (!draw.rhw.empty())
geometry["rhw"] = floats(draw.rhw.data(), static_cast<int>(draw.rhw.size()));
if (!draw.normals.empty()) {
PackedVector3Array normals;
normals.resize(static_cast<int64_t>(draw.normals.size() / 3));
if constexpr (sizeof(Vector3) == sizeof(float) * 3 && std::is_trivially_copyable_v<Vector3>) {
std::memcpy(normals.ptrw(), draw.normals.data(), draw.normals.size() * sizeof(float));
} else {
Vector3 *dst = normals.ptrw();
for (int64_t i = 0; i < normals.size(); ++i)
dst[i] = Vector3(draw.normals[i * 3], draw.normals[i * 3 + 1], draw.normals[i * 3 + 2]);
}
geometry["normals"] = normals;
}
auto uvs = [](const std::vector<float> &values) {
PackedVector2Array out;
out.resize(static_cast<int64_t>(values.size() / 2));
if constexpr (sizeof(Vector2) == sizeof(float) * 2 && std::is_trivially_copyable_v<Vector2>) {
if (!values.empty())
std::memcpy(out.ptrw(), values.data(), values.size() * sizeof(float));
} else {
Vector2 *dst = out.ptrw();
for (int64_t i = 0; i < out.size(); ++i)
dst[i] = Vector2(values[i * 2], values[i * 2 + 1]);
}
return out;
};
if (stage0_texgen) {
std::vector<float> stage_uv;
Render3DStageTexcoords(draw, 0, stage_uv);
geometry["uv0"] = uvs(stage_uv);
} else if (!draw.uv0.empty())
geometry["uv0"] = uvs(draw.uv0);
if (!draw.uv1.empty())
geometry["uv1"] = uvs(draw.uv1);
if (!draw.diffuse.empty()) {
PackedColorArray colors;
colors.resize(static_cast<int64_t>(draw.diffuse.size()));
for (int64_t i = 0; i < colors.size(); ++i)
colors[i] = argb_color(draw.diffuse[i]);
geometry["diffuse"] = colors;
}
PackedInt32Array indices;
indices.resize(static_cast<int64_t>(draw.indices.size()));
for (int64_t i = 0; i < indices.size(); ++i)
indices[i] = static_cast<int32_t>(draw.indices[i]);
geometry["indices"] = indices;
if (draw.geometry_key != 0 && !stage0_texgen)
geometry_cache[draw.geometry_key] = {draw.geometry_revision, extraction, geometry};
}
item.merge(geometry);
item["alpha_blend"] = static_cast<int64_t>(draw.alpha_blend);
item["src_blend"] = static_cast<int64_t>(draw.src_blend);
item["dest_blend"] = static_cast<int64_t>(draw.dest_blend);
item["alpha_test"] = static_cast<int64_t>(draw.alpha_test);
item["alpha_ref"] = static_cast<int64_t>(draw.alpha_ref);
item["alpha_func"] = static_cast<int64_t>(draw.alpha_func);
item["cull_mode"] = static_cast<int64_t>(draw.cull_mode);
item["z_enable"] = static_cast<int64_t>(draw.z_enable);
item["z_write"] = static_cast<int64_t>(draw.z_write);
item["z_func"] = static_cast<int64_t>(draw.z_func);
item["lighting"] = static_cast<int64_t>(draw.lighting);
item["texture_factor"] = static_cast<int64_t>(draw.texture_factor);
bool uses_tf = false;
for (int s = 0; s < 2; ++s) {
if (draw.color_op[s] > 1 &&
(((draw.color_arg1[s] & 0xF) == 3) || ((draw.color_arg2[s] & 0xF) == 3)))
uses_tf = true;
if (draw.alpha_op[s] > 1 &&
(((draw.alpha_arg1[s] & 0xF) == 3) || ((draw.alpha_arg2[s] & 0xF) == 3)))
uses_tf = true;
}
item["uses_tf"] = uses_tf;
item["fog_enable"] = static_cast<int64_t>(draw.fog_enable);
item["color_op"] = static_cast<int64_t>(draw.color_op[0]);
item["alpha_op"] = static_cast<int64_t>(draw.alpha_op[0]);
item["material_diffuse"] = Color(draw.material_diffuse[0], draw.material_diffuse[1],
draw.material_diffuse[2], draw.material_diffuse[3]);
item["material_ambient"] = Color(draw.material_ambient[0], draw.material_ambient[1],
draw.material_ambient[2], draw.material_ambient[3]);
item["material_emissive"] = Color(draw.material_emissive[0], draw.material_emissive[1],
draw.material_emissive[2], draw.material_emissive[3]);
item["light0"] = draw.light0;
item["light0_direction"] = Vector3(draw.light0_direction[0], draw.light0_direction[1],
draw.light0_direction[2]);
item["light0_diffuse"] = Color(draw.light0_diffuse[0], draw.light0_diffuse[1], draw.light0_diffuse[2],
draw.light0_diffuse[3]);
item["light0_ambient"] = Color(draw.light0_ambient[0], draw.light0_ambient[1], draw.light0_ambient[2],
draw.light0_ambient[3]);
item["ambient"] = argb_color(draw.ambient);
PackedFloat32Array vp;
vp.resize(4);
vp[0] = draw.viewport[0];
vp[1] = draw.viewport[1];
vp[2] = draw.viewport[2];
vp[3] = draw.viewport[3];
item["viewport"] = vp;
out[draw_index++] = item;
}
out.resize(draw_index);
if (extraction % 120 == 0) {
for (auto it = geometry_cache.begin(); it != geometry_cache.end();) {
if (extraction - it->second.last_used > 120)
it = geometry_cache.erase(it);
else
++it;
}
}
return out;
}
Array Metin2PythonHost::audio_commands() {
Array out;
for (const auto &cmd : DrainAudioCommands()) {
Dictionary item;
switch (cmd.type) {
case AudioCommand::PlaySound2D: item["type"] = "play_sound_2d"; break;
case AudioCommand::PlaySound3D: item["type"] = "play_sound_3d"; break;
case AudioCommand::StopSound3D: item["type"] = "stop_sound_3d"; break;
case AudioCommand::StopAllSound3D: item["type"] = "stop_all_sound_3d"; break;
case AudioCommand::PlayMusic: item["type"] = "play_music"; break;
case AudioCommand::FadeOutMusic: item["type"] = "fade_out_music"; break;
case AudioCommand::FadeOutAllMusic: item["type"] = "fade_out_all_music"; break;
case AudioCommand::SetSoundVolume: item["type"] = "set_sound_volume"; break;
case AudioCommand::SetMusicVolume: item["type"] = "set_music_volume"; break;
case AudioCommand::FadeInMusic: item["type"] = "fade_in_music"; break;
case AudioCommand::FadeLimitOutMusic: item["type"] = "fade_limit_out_music"; break;
case AudioCommand::SetSoundVolume3D: item["type"] = "set_sound_volume_3d"; break;
default: continue;
}
item["filename"] = String::utf8(cmd.filename.c_str());
item["x"] = cmd.x;
item["y"] = cmd.y;
item["z"] = cmd.z;
item["volume"] = cmd.volume;
item["speed"] = cmd.speed;
item["play_count"] = cmd.play_count;
item["id"] = cmd.id;
out.append(item);
}
return out;
}
String Metin2PythonHost::current_map_name() {
return String::utf8(PythonBoot::CurrentMapName().c_str());
}
void Metin2PythonHost::stop() {
PythonBoot::Stop();
}
#else
// Built without -DMTGODOT_EMBED_PYTHON=ON: the class is still registered so GDScript can tell "no
// interpreter in this build" apart from "the interpreter failed to start".
String Metin2PythonHost::start(const String &) {
return String("this build has no embedded Python (-DMTGODOT_EMBED_PYTHON=ON)");
}
bool Metin2PythonHost::is_running() {
return false;
}
String Metin2PythonHost::run_main_script(const String &) {
return String("this build has no embedded Python (-DMTGODOT_EMBED_PYTHON=ON)");
}
String Metin2PythonHost::run_line(const String &) {
return String("this build has no embedded Python (-DMTGODOT_EMBED_PYTHON=ON)");
}
String Metin2PythonHost::evaluate(const String &) { return String(); }
bool Metin2PythonHost::is_app_looping() { return false; }
void Metin2PythonHost::set_ui_size(int, int) {}
void Metin2PythonHost::ui_mouse_move(int, int) {}
void Metin2PythonHost::ui_mouse_button(int, bool, int, int) {}
void Metin2PythonHost::ui_mouse_wheel(int) {}
void Metin2PythonHost::ui_key(int, bool) {}
void Metin2PythonHost::ui_char(int) {}
void Metin2PythonHost::ui_ime_key(int) {}
void Metin2PythonHost::ui_update() {}
Array Metin2PythonHost::ui_render_commands() { return Array(); }
Array Metin2PythonHost::ui_render_commands_batched() { return Array(); }
bool Metin2PythonHost::has_3d_draws() { return false; }
Ref<Image> Metin2PythonHost::memory_texture(const String &, int64_t) { return Ref<Image>(); }
Array Metin2PythonHost::render3d_draws() { return Array(); }
Array Metin2PythonHost::audio_commands() { return Array(); }
String Metin2PythonHost::current_map_name() { return String(); }
void Metin2PythonHost::stop() {
}
#endif
void Metin2PythonHost::_bind_methods() {
ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("start", "stdlib_path"),
&Metin2PythonHost::start, DEFVAL(String()));
ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("is_running"), &Metin2PythonHost::is_running);
ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("evaluate", "expression"), &Metin2PythonHost::evaluate);
ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("run_main_script", "command_line"),
&Metin2PythonHost::run_main_script, DEFVAL(String()));
ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("run_line", "source"), &Metin2PythonHost::run_line);
ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("is_app_looping"), &Metin2PythonHost::is_app_looping);
ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("set_ui_size", "width", "height"), &Metin2PythonHost::set_ui_size);
ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("ui_mouse_move", "x", "y"), &Metin2PythonHost::ui_mouse_move);
ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("ui_mouse_button", "button", "pressed", "x", "y"), &Metin2PythonHost::ui_mouse_button);
ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("ui_mouse_wheel", "delta"), &Metin2PythonHost::ui_mouse_wheel);
ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("ui_key", "key", "pressed"), &Metin2PythonHost::ui_key);
ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("ui_char", "codepoint"), &Metin2PythonHost::ui_char);
ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("ui_ime_key", "vkey"), &Metin2PythonHost::ui_ime_key);
ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("ui_update"), &Metin2PythonHost::ui_update);
ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("ui_render_commands"), &Metin2PythonHost::ui_render_commands);
ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("ui_render_commands_batched"), &Metin2PythonHost::ui_render_commands_batched);
ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("has_3d_draws"), &Metin2PythonHost::has_3d_draws);
ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("memory_texture", "name", "known_revision"),
&Metin2PythonHost::memory_texture, DEFVAL(-1));
ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("render3d_draws"), &Metin2PythonHost::render3d_draws);
ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("audio_commands"), &Metin2PythonHost::audio_commands);
ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("current_map_name"), &Metin2PythonHost::current_map_name);
ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("stop"), &Metin2PythonHost::stop);
}
} // namespace mtgodot
-66
View File
@@ -1,66 +0,0 @@
#pragma once
// Metin2PythonHost — the 40250 script layer inside the Godot process (docs/PORT-PLAN.md 批次 2P step 4).
//
// Metin2Pack.initialize(AssetRoot.client_path()) # the packs system.py is read from
// if Metin2PythonHost.start() == "":
// var err := Metin2PythonHost.run_main_script("")
//
// Both calls answer with "" on success and with the reason otherwise, so GDScript never has to guess
// from a bool. 2V0-e reaches prototype.RunApp(); until 2V0-f implements the application adapter,
// app.Create reports CREATE_DEVICE and the original script displays its error popup.
//
// This is the only unit that knows both godot-cpp and the port tree: the sequence itself lives in
// platform/ScriptLib/PythonBoot.h, which stays godot-free.
#include <godot_cpp/classes/object.hpp>
#include <godot_cpp/variant/string.hpp>
#include <godot_cpp/variant/array.hpp>
#include <godot_cpp/classes/image.hpp>
#include <godot_cpp/classes/ref.hpp>
namespace mtgodot {
class Metin2PythonHost : public godot::Object {
GDCLASS(Metin2PythonHost, godot::Object)
public:
// Stages the standard library (Metin2Python::stdlib_path) when `stdlib_path` is empty, then starts
// the interpreter. "" on success, including when it was already running.
static godot::String start(const godot::String &stdlib_path);
static bool is_running();
// __DEBUG__, __COMMAND_LINE__ and system.py out of the packs. "" when system.py ran to the end.
static godot::String run_main_script(const godot::String &command_line);
// One line of Python in the same __main__ dictionary. "" when it ran.
static godot::String run_line(const godot::String &source);
static godot::String evaluate(const godot::String &expression);
// True while system.py is inside app.Loop(); ui_update() then runs one CPythonApplication::Process().
static bool is_app_looping();
static void set_ui_size(int width, int height);
static void ui_mouse_move(int x, int y);
static void ui_mouse_button(int button, bool pressed, int x, int y);
static void ui_mouse_wheel(int delta);
static void ui_key(int key, bool pressed);
// WM_CHAR (Unicode code point) and WM_KEYDOWN (Win32 VK code) for the 40250 IME.
static void ui_char(int codepoint);
static void ui_ime_key(int vkey);
static void ui_update();
static godot::Array ui_render_commands();
// Ordered UI commands with consecutive, unclipped font quads packed into triangle arrays.
static godot::Array ui_render_commands_batched();
static bool has_3d_draws();
// The pixels of a "mem:<id>@<revision>" image command (the CGraphicFontTexture glyph pages) as an
// RGBA8 Image; null when the texture is gone or its revision is still `known_revision`.
static godot::Ref<godot::Image> memory_texture(const godot::String &name, int64_t known_revision);
// The 3D draw calls of the last ui_update() (RenderCommands3D.h), one Dictionary each: "world",
// "view", "proj" (PackedFloat32Array, D3D row-vector layout), vertex arrays, "indices", "texture0"
// and the blend/depth/cull/lighting state.
static godot::Array render3d_draws();
static godot::Array audio_commands();
static godot::String current_map_name();
static void stop();
protected:
static void _bind_methods();
};
} // namespace mtgodot
-133
View File
@@ -1,133 +0,0 @@
#include "python_stdlib.h"
#include <godot_cpp/classes/dir_access.hpp>
#include <godot_cpp/classes/file_access.hpp>
#include <godot_cpp/classes/project_settings.hpp>
#include <godot_cpp/core/class_db.hpp>
#include <godot_cpp/variant/utility_functions.hpp>
#include <string>
#include <cstdio>
#ifdef MTGODOT_HAVE_PYTHON
#include "platform/ScriptLib/PythonHost.h"
#endif
using namespace godot;
namespace mtgodot {
namespace {
// Both ship in the PCK; the build copies them next to project.godot (extension/CMakeLists.txt).
constexpr const char *ZIP_RES = "res://python27.zip";
constexpr const char *SHA_RES = "res://python27.zip.sha256";
constexpr const char *ZIP_USER = "user://python27.zip";
constexpr const char *ZIP_USER_TMP = "user://python27.zip.part";
// std::string, not String: this lives at file scope and a godot::String's constructor would run at
// dlopen time, before the extension's bindings exist.
std::string g_last_error;
String fail(String *error, const String &text) {
g_last_error = text.utf8().get_data();
if (error) {
*error = text;
}
return String();
}
String expected_sha() {
if (!FileAccess::file_exists(SHA_RES)) {
return String();
}
return FileAccess::get_file_as_string(SHA_RES).strip_edges();
}
} // namespace
String stage_python_stdlib(String *error, bool force_stage) {
g_last_error.clear();
if (error) {
*error = String();
}
if (!FileAccess::file_exists(ZIP_RES)) {
return fail(error, String("no ") + ZIP_RES +
" (build with -DMTGODOT_EMBED_PYTHON=ON, which copies it into project/)");
}
ProjectSettings *settings = ProjectSettings::get_singleton();
// Desktop and the editor: res:// is a directory on disk, so CPython can open the file in place and
// nothing is copied. In an exported game globalize_path() returns a path next to the executable
// that does not exist, which is exactly the case the staging below is for.
const String in_place = settings->globalize_path(ZIP_RES);
if (!force_stage && !in_place.begins_with("res://")) {
// FileAccess may resolve an exported PCK entry through a relative globalized path. CPython
// uses fopen(), so only return the path when libc can open a real on-disk zip there.
if (FILE *file = std::fopen(in_place.utf8().get_data(), "rb")) {
std::fclose(file);
return in_place;
}
}
const String want = expected_sha();
if (want.is_empty()) {
return fail(error, String("no ") + SHA_RES + " beside the stdlib zip");
}
// An already-staged copy is reused only when it is the zip this build ships: a new version of the
// app brings a new digest, and a first start that was killed mid-copy leaves a short file.
if (FileAccess::file_exists(ZIP_USER) && FileAccess::get_sha256(ZIP_USER) == want) {
return settings->globalize_path(ZIP_USER);
}
const PackedByteArray data = FileAccess::get_file_as_bytes(ZIP_RES);
if (data.is_empty()) {
return fail(error, String("cannot read ") + ZIP_RES);
}
{
Ref<FileAccess> out = FileAccess::open(ZIP_USER_TMP, FileAccess::WRITE);
if (out.is_null()) {
return fail(error, String("cannot write ") + ZIP_USER_TMP + ": " +
itos((int) FileAccess::get_open_error()));
}
out->store_buffer(data);
out->close();
}
// Hash the file as written, not the buffer: a full disk reports itself here and not later as a
// zipimport failure three imports into system.py.
if (FileAccess::get_sha256(ZIP_USER_TMP) != want) {
DirAccess::remove_absolute(ZIP_USER_TMP);
return fail(error, String("staged ") + ZIP_USER + " does not match " + want);
}
if (FileAccess::file_exists(ZIP_USER)) {
DirAccess::remove_absolute(ZIP_USER);
}
if (DirAccess::rename_absolute(ZIP_USER_TMP, ZIP_USER) != OK) {
return fail(error, String("cannot rename ") + ZIP_USER_TMP + " to " + ZIP_USER);
}
UtilityFunctions::print("[python27] staged ", ZIP_USER, " (", data.size(), " bytes, sha256 ", want, ")");
return settings->globalize_path(ZIP_USER);
}
String Metin2Python::stdlib_path(bool force_stage) {
const String path = stage_python_stdlib(nullptr, force_stage);
#ifdef MTGODOT_HAVE_PYTHON
if (!path.is_empty()) {
PythonHost::SetDefaultStdLibPath(path.utf8().get_data());
}
#endif
return path;
}
String Metin2Python::last_error() {
return String::utf8(g_last_error.c_str());
}
void Metin2Python::_bind_methods() {
ClassDB::bind_static_method("Metin2Python", D_METHOD("stdlib_path", "force_stage"),
&Metin2Python::stdlib_path, DEFVAL(false));
ClassDB::bind_static_method("Metin2Python", D_METHOD("last_error"), &Metin2Python::last_error);
}
} // namespace mtgodot
-37
View File
@@ -1,37 +0,0 @@
#pragma once
// python_stdlib — where the embedded interpreter's standard library is on this platform
// (docs/PORT-PLAN.md 批次 2P step 3c).
//
// CPython opens python27.zip with its own stdio, so it needs a real filesystem path. That is free on
// the desktop, where res:// is a directory on disk, but on Android and iOS res:// is inside the PCK,
// which nothing outside Godot can read. There the zip is copied once into the sandbox (user://) and
// the copy is accepted only when it hashes to the digest built beside the zip, so an interrupted
// first start or a zip replaced by a new build re-stages instead of feeding zipimport a torn file.
//
// Only this file knows about res:// / user://: PythonHost (platform/ScriptLib) stays godot-free and
// is told the path, through PythonHost::SetDefaultStdLibPath.
#include <godot_cpp/classes/object.hpp>
#include <godot_cpp/variant/string.hpp>
namespace mtgodot {
// The real filesystem path of python27.zip, staging it out of res:// when res:// is not on disk.
// Empty when the zip is missing or the staged copy does not verify; the reason goes to *error.
// force_stage runs the mobile path even where res:// is readable, which is how it is tested.
godot::String stage_python_stdlib(godot::String *error = nullptr, bool force_stage = false);
class Metin2Python : public godot::Object {
GDCLASS(Metin2Python, godot::Object)
public:
// Returns the path and, when the embedded interpreter is built in, hands it to PythonHost so
// PythonHost::DefaultStdLibPath() answers with it from then on.
static godot::String stdlib_path(bool force_stage);
static godot::String last_error();
protected:
static void _bind_methods();
};
} // namespace mtgodot
-82
View File
@@ -1,82 +0,0 @@
#include "register_types.h"
#include <gdextension_interface.h>
#include <godot_cpp/core/defs.hpp>
#include <godot_cpp/godot.hpp>
#include "asset_io.h"
#include "metin2_anim.h"
#include "metin2_model.h"
#include "metin2_world.h"
#include "m2_material.h"
#include "net/m2_client.h"
#include "pack40250_node.h"
#include "proto/proto_node.h"
#include "python_host_node.h"
#include "python_stdlib.h"
#include "static_object.h"
#include "terrain_splat.h"
#include "tree_placeholder.h"
#include "water_builder.h"
#include <m2_tokvec.h> // fmt::set_file_reader
#include <string>
using namespace godot;
// formats/ reads every asset (maps, .msenv, .msm/.msa via textscript, .spt, …)
// through fmt::read_file. Route it through godot::FileAccess so it works from
// res:// (the PCK) on the read-only iOS/Android bundles as well as loose dev files.
static bool mt_fmt_read_file(const std::string &path, std::string &out) {
PackedByteArray b = mtgodot::read_file(String::utf8(path.c_str()));
if (b.is_empty()) {
return false;
}
out.assign(reinterpret_cast<const char *>(b.ptr()), (size_t)b.size());
return true;
}
void initialize_mtgodot_module(ModuleInitializationLevel p_level) {
if (p_level != MODULE_INITIALIZATION_LEVEL_SCENE) {
return;
}
fmt::set_file_reader(&mt_fmt_read_file);
GDREGISTER_CLASS(mtgodot::Metin2Model);
GDREGISTER_CLASS(mtgodot::Metin2AnimPlayer);
GDREGISTER_CLASS(mtgodot::Metin2World);
GDREGISTER_CLASS(mtgodot::M2Client);
GDREGISTER_CLASS(mtgodot::Metin2Proto);
GDREGISTER_CLASS(mtgodot::Metin2Pack);
GDREGISTER_CLASS(mtgodot::Metin2Python);
GDREGISTER_CLASS(mtgodot::Metin2PythonHost);
}
void uninitialize_mtgodot_module(ModuleInitializationLevel p_level) {
if (p_level == MODULE_INITIALIZATION_LEVEL_SCENE) {
fmt::set_file_reader(nullptr);
mtgodot::Metin2AnimPlayer::clear_clip_cache();
// CPythonApplication::Destroy / CResourceManager teardown releases
// model-owned decoded textures before Godot's ObjectDB is destroyed.
// Leaving this static Ref cache alive makes renderer shutdown report
// leaked ImageTexture objects after a real model/map scenario.
mtgodot::Metin2Model::clear_texture_cache();
mtgodot::cleanup_material_shaders();
mtgodot::cleanup_terrain_shader();
mtgodot::cleanup_tree_shader();
mtgodot::cleanup_static_object_cache();
mtgodot::cleanup_water_shader();
}
}
extern "C" {
GDExtensionBool GDE_EXPORT mtgodot_library_init(
GDExtensionInterfaceGetProcAddress p_get_proc_address,
const GDExtensionClassLibraryPtr p_library,
GDExtensionInitialization *r_initialization) {
godot::GDExtensionBinding::InitObject init_obj(p_get_proc_address, p_library, r_initialization);
init_obj.register_initializer(initialize_mtgodot_module);
init_obj.register_terminator(uninitialize_mtgodot_module);
init_obj.set_minimum_library_initialization_level(MODULE_INITIALIZATION_LEVEL_SCENE);
return init_obj.init();
}
}
-6
View File
@@ -1,6 +0,0 @@
#pragma once
#include <godot_cpp/core/class_db.hpp>
void initialize_mtgodot_module(godot::ModuleInitializationLevel p_level);
void uninitialize_mtgodot_module(godot::ModuleInitializationLevel p_level);
-163
View File
@@ -1,163 +0,0 @@
#include "static_object.h"
#include "asset_io.h"
#include "dxt.h"
#include "gr2_bridge.h"
#include "texture_util.h"
#include <godot_cpp/classes/image.hpp>
#include <godot_cpp/classes/image_texture.hpp>
#include <godot_cpp/classes/standard_material3d.hpp>
#include <godot_cpp/variant/packed_byte_array.hpp>
#include <godot_cpp/variant/utility_functions.hpp>
#include <asset_resolver.h>
#include <gr2/gr2.h>
#include <unordered_map>
using namespace godot;
namespace mtgodot {
namespace {
std::unordered_map<std::string, Ref<ImageTexture>> g_dds_cache;
// Building albedo (sRGB colour) -> mobile-ASTC-eligible via make_color_texture
// (no-op on desktop). mipmaps=false keeps the desktop result byte-identical to
// the old path (buildings had no mip chain). §F4.
Ref<ImageTexture> decode_dds_cached(const std::string &real_path) {
auto &cache = g_dds_cache;
auto it = cache.find(real_path);
if (it != cache.end())
return it->second;
Ref<ImageTexture> tex;
mtgodot::Image d = mtgodot::dds_from_file(godot::String(real_path.c_str()));
if (d.ok()) {
tex = mtgodot::make_color_texture(d.w, d.h, d.rgba.data(), d.rgba.size(),
/*mipmaps=*/false);
}
cache.emplace(real_path, tex);
return tex;
}
Ref<StandardMaterial3D> material_for(const std::string &tex_name, bool alpha_blend, bool two_sided,
const fmt::AssetResolver &res) {
Ref<StandardMaterial3D> mat;
mat.instantiate();
mat->set_albedo(Color(0.7f, 0.7f, 0.7f));
mat->set_roughness(1.0f);
mat->set_texture_filter(StandardMaterial3D::TEXTURE_FILTER_LINEAR_WITH_MIPMAPS_ANISOTROPIC);
mat->set_cull_mode(StandardMaterial3D::CULL_DISABLED);
mat->set_diffuse_mode(StandardMaterial3D::DIFFUSE_LAMBERT_WRAP);
mat->set_specular_mode(StandardMaterial3D::SPECULAR_DISABLED);
mat->set_feature(StandardMaterial3D::FEATURE_BACKLIGHT, true);
mat->set_backlight(Color(0.2f, 0.2f, 0.2f));
String t = String(tex_name.c_str()).to_lower();
bool kw_alpha = t.find("leaf") != -1 || t.find("grass") != -1 || t.find("fence") != -1 ||
t.find("net") != -1 || t.find("ivy") != -1 || t.find("tree") != -1 ||
t.find("branch") != -1;
if (alpha_blend) {
// EterGrnLib TYPE_BLEND_PNT:真 alpha 混合(第 2 map 作 opacity)
mat->set_transparency(StandardMaterial3D::TRANSPARENCY_ALPHA);
mat->set_cull_mode(StandardMaterial3D::CULL_DISABLED);
} else if (kw_alpha) {
mat->set_transparency(StandardMaterial3D::TRANSPARENCY_ALPHA_SCISSOR);
mat->set_alpha_scissor_threshold(0.5f);
mat->set_cull_mode(StandardMaterial3D::CULL_DISABLED);
}
if (two_sided)
mat->set_cull_mode(StandardMaterial3D::CULL_DISABLED);
if (tex_name.empty())
return mat;
std::string rp = res.resolve(tex_name, nullptr);
if (rp.empty()) {
// gr2 里常是 .dds;有时资产用别的大小写 / 扩展。先只试原名。
return mat;
}
Ref<ImageTexture> tex = decode_dds_cached(rp);
if (tex.is_valid()) {
mat->set_texture(StandardMaterial3D::TEXTURE_ALBEDO, tex);
mat->set_albedo(Color(1, 1, 1));
mat->set_feature(StandardMaterial3D::FEATURE_EMISSION, true);
mat->set_texture(StandardMaterial3D::TEXTURE_EMISSION, tex);
mat->set_emission(Color(1.0f, 1.0f, 1.0f));
mat->set_emission_energy_multiplier(0.18f);
}
return mat;
}
} // namespace
Ref<godot::ArrayMesh> get_static_mesh(const std::string &real_gr2_path,
const fmt::AssetResolver &res, StaticMeshCache &cache) {
auto it = cache.by_path.find(real_gr2_path);
if (it != cache.by_path.end())
return it->second;
Ref<godot::ArrayMesh> result; // invalid until success
gr2::LoadError err;
auto loaded = mtgodot::gr2_from_file(godot::String(real_gr2_path.c_str()), &err);
if (!loaded) {
++cache.failed;
UtilityFunctions::push_warning(String("[static] gr2 load failed: ") +
real_gr2_path.c_str() + " (" + err.message.c_str() + ")");
cache.by_path.emplace(real_gr2_path, result);
return result;
}
const gr2::FileInfo &fi = loaded->file_info();
// 贴图名 -> 渲染态(来自 libgr2 dump_materials 的名字/map 推断)
std::vector<gr2::MaterialInfo> mats = gr2::dump_materials(*loaded);
std::map<std::string, std::pair<bool, bool>> tex_state; // lower(tex) -> {alpha, two_sided}
for (const auto &m : mats) {
std::string k;
for (char c : m.diffuse_texture)
k += (char)std::tolower((unsigned char)c);
if (!k.empty())
tex_state[k] = {m.alpha_blend, m.two_sided};
}
std::vector<mtgodot::RenderPart> parts = mtgodot::build_parts(fi);
AABB bounds;
Ref<godot::ArrayMesh> mesh = mtgodot::build_mesh(fi, parts, /*flip_winding=*/false, bounds);
if (mesh.is_valid() && mesh->get_surface_count() > 0) {
for (int s = 0; s < mesh->get_surface_count() && s < (int)parts.size(); ++s) {
const mtgodot::RenderPart &rp = parts[s];
std::string tex;
if (rp.mesh >= 0 && rp.mesh < (int)fi.meshes.size()) {
const auto &mt = fi.meshes[rp.mesh].material_textures;
if (rp.mat_index >= 0 && rp.mat_index < (int)mt.size())
tex = mt[rp.mat_index];
else if (!mt.empty())
tex = mt[0];
}
bool alpha = false, two = false;
{
std::string k;
for (char c : tex)
k += (char)std::tolower((unsigned char)c);
auto f = tex_state.find(k);
if (f != tex_state.end()) {
alpha = f->second.first;
two = f->second.second;
}
}
mesh->surface_set_material(s, material_for(tex, alpha, two, res));
}
result = mesh;
++cache.loaded;
} else {
++cache.failed;
}
cache.by_path.emplace(real_gr2_path, result);
return result;
}
void cleanup_static_object_cache() { g_dds_cache.clear(); }
} // namespace mtgodot
-35
View File
@@ -1,35 +0,0 @@
#pragma once
#include <godot_cpp/classes/array_mesh.hpp>
#include <godot_cpp/classes/ref.hpp>
#include <map>
#include <string>
namespace fmt {
struct AssetResolver;
}
namespace mtgodot {
// W3 —— 静态(无骨骼)GR2 对象。用 gr2_bridge 的 build_parts/build_mesh,
// 逐 surface 从 gr2 material binding 取贴图(走 AssetResolver 定位 + dxt 解码),
// 不建 Skeleton3D、不逐帧蒙皮。Building / DungeonBlock 用。SHINSOO §9-W3。
//
// SHINSOO §9-W3「Metin2StaticModel 新节点 vs 拆 Metin2Model」的决策:
// 取轻量方案 —— 一个自由函数产出带材质的共享 ArrayMesh,调用方(Metin2World)
// 直接挂到 MeshInstance3D,跨实例共享同一份 mesh。
struct StaticMeshCache {
std::map<std::string, godot::Ref<godot::ArrayMesh>> by_path;
int loaded = 0, failed = 0;
};
// real_gr2_path = AssetResolver 解析后的真实路径。返回共享 ArrayMesh(含材质)。
// 失败返回 invalid Ref。
godot::Ref<godot::ArrayMesh> get_static_mesh(const std::string &real_gr2_path,
const fmt::AssetResolver &res, StaticMeshCache &cache);
// 退出时清缓存的 Ref<Image>(别拖到 __cxa_finalize)。
void cleanup_static_object_cache();
} // namespace mtgodot
-264
View File
@@ -1,264 +0,0 @@
#include "terrain_splat.h"
#include "asset_io.h"
#include "dxt.h"
#include <godot_cpp/classes/image.hpp>
#include <godot_cpp/classes/image_texture.hpp>
#include <godot_cpp/classes/shader.hpp>
#include <godot_cpp/classes/texture2d_array.hpp>
#include <godot_cpp/variant/color.hpp>
#include <godot_cpp/variant/packed_byte_array.hpp>
#include <godot_cpp/variant/typed_array.hpp>
#include <asset_resolver.h>
#include <algorithm>
#include <chrono>
#include <cstdio>
#include <cstdlib>
#include <unordered_map>
#include <utility>
using namespace godot;
namespace mtgodot {
// 最多 16 层:A1 单区块实测最多 12 个活动图层(`000004`/`003004`),旧的 8 层上限会静默丢层。
static const int MAX_LAYERS = 16;
namespace {
const char *SRC_TERRAIN = R"(shader_type spatial;
render_mode diffuse_lambert, specular_disabled, cull_back;
uniform sampler2DArray layers : source_color, filter_linear_mipmap_anisotropic, repeat_enable;
uniform sampler2D weights0 : filter_linear; // RGBA = layer 0..3 alpha
uniform sampler2D weights1 : filter_linear; // RGBA = layer 4..7 alpha
uniform sampler2D weights2 : filter_linear; // RGBA = layer 8..11 alpha
uniform sampler2D weights3 : filter_linear; // RGBA = layer 12..15 alpha
uniform vec4 layer_uv[16]; // xy = 每区块平铺频率 (8*Scale),zw = offset
uniform int layer_count = 0;
uniform sampler2D shadowmap : source_color, filter_linear;
uniform bool use_shadowmap = false;
void fragment() {
vec3 col = vec3(0.32, 0.30, 0.24);
vec4 w[4];
w[0] = texture(weights0, UV);
w[1] = texture(weights1, UV);
w[2] = texture(weights2, UV);
w[3] = texture(weights3, UV);
for (int i = 0; i < 16; i++) {
if (i >= layer_count) { break; }
float wi = w[i >> 2][i & 3];
if (wi <= 0.003) { continue; }
vec2 tuv = UV * layer_uv[i].xy + layer_uv[i].zw;
vec3 lc = texture(layers, vec3(tuv, float(i))).rgb;
col = mix(col, lc, wi);
}
if (use_shadowmap) {
col *= texture(shadowmap, UV).rgb;
}
ALBEDO = col;
ROUGHNESS = 1.0;
}
)";
Ref<Shader> g_terrain_shader;
Ref<Shader> terrain_shader() {
if (g_terrain_shader.is_null()) {
g_terrain_shader.instantiate();
g_terrain_shader->set_code(SRC_TERRAIN);
}
return g_terrain_shader;
}
// DDS -> RGBA8 Image,resize 到 size×size。缓存(非函数静态 —— 见 cleanup)。
std::unordered_map<std::string, Ref<godot::Image>> g_layer_cache;
std::unordered_map<std::string, std::pair<int, int>> g_layer_dimensions;
Ref<godot::Image> layer_image(const std::string &real_path, int size,
std::unordered_map<std::string, mtgodot::Image> &decoded_sources) {
auto &cache = g_layer_cache;
std::string key = real_path + "@" + std::to_string(size);
auto it = cache.find(key);
if (it != cache.end())
return it->second;
Ref<godot::Image> out;
mtgodot::Image d;
auto decoded = decoded_sources.find(real_path);
if (decoded != decoded_sources.end()) {
d = std::move(decoded->second);
decoded_sources.erase(decoded);
} else {
d = mtgodot::dds_from_file(godot::String(real_path.c_str()));
}
if (d.ok()) {
PackedByteArray b;
b.resize((int64_t)d.rgba.size());
std::copy(d.rgba.begin(), d.rgba.end(), b.ptrw());
out = godot::Image::create_from_data(d.w, d.h, false, godot::Image::FORMAT_RGBA8, b);
if (out.is_valid() && (d.w != size || d.h != size))
out->resize(size, size, godot::Image::INTERPOLATE_BILINEAR);
if (out.is_valid())
out->generate_mipmaps();
}
cache.emplace(key, out);
return out;
}
// SplatLayer.alpha (258²) -> 256² 的某个通道
void pack_channel(uint8_t *dst /*256*256*4*/, int ch, const std::vector<uint8_t> &alpha258) {
const int S = fmt::SPLAT_RAW_XY; // 258
for (int y = 0; y < 256; ++y)
for (int x = 0; x < 256; ++x)
dst[(y * 256 + x) * 4 + ch] = alpha258[size_t(y + 1) * S + (x + 1)];
}
Ref<ImageTexture> weight_tex(const std::vector<const fmt::SplatLayer *> &four) {
std::vector<uint8_t> buf(size_t(256) * 256 * 4, 0);
for (int c = 0; c < 4 && c < (int)four.size(); ++c)
if (four[c])
pack_channel(buf.data(), c, four[c]->alpha);
PackedByteArray b;
b.resize((int64_t)buf.size());
std::copy(buf.begin(), buf.end(), b.ptrw());
Ref<godot::Image> img =
godot::Image::create_from_data(256, 256, false, godot::Image::FORMAT_RGBA8, b);
return ImageTexture::create_from_image(img);
}
} // namespace
void cleanup_terrain_shader() {
g_terrain_shader.unref();
g_layer_cache.clear(); // 释放缓存的 Ref<Image>,别拖到 __cxa_finalize(那时引擎已析构)
g_layer_dimensions.clear();
}
Ref<ShaderMaterial> build_chunk_terrain_material(const fmt::SplatSet &splat,
const fmt::TextureSet &tset, const fmt::AssetResolver &res,
const String &shadowmap_path) {
const bool profile = std::getenv("MT_PROFILE_MAP") != nullptr;
const auto now = [] { return std::chrono::steady_clock::now(); };
const auto start = now();
// Texture2DArray 要求各 slice 同尺寸 —— 取用到的图层里的最大源边长(上限 1024),
// 只放大不缩小最大源,避免把 512² 地表贴图硬降采样(PARITY-GAP §3.4)。
int src_max = 256;
// Keep newly decoded sources only until the image pass consumes them. The size pass and image pass
// used to decode the same DDS twice on its first use.
std::unordered_map<std::string, mtgodot::Image> decoded_sources;
for (const auto &L : splat.layers) {
if (L.layer >= 1 && L.layer <= (int)tset.layers.size()) {
std::string rp = res.resolve(tset.layers[L.layer - 1].texture, nullptr);
if (rp.empty())
continue;
auto it = g_layer_dimensions.find(rp);
if (it == g_layer_dimensions.end()) {
mtgodot::Image d = mtgodot::dds_from_file(godot::String(rp.c_str()));
it = g_layer_dimensions.emplace(rp, d.ok() ? std::make_pair(int(d.w), int(d.h))
: std::make_pair(0, 0)).first;
decoded_sources.emplace(rp, std::move(d));
}
src_max = std::max(src_max, std::max(it->second.first, it->second.second));
}
}
const int LSIZE = std::min(1024, src_max);
const auto dimensions_done = now();
int n = std::min<int>(MAX_LAYERS, (int)splat.layers.size());
if (n == 0)
return Ref<ShaderMaterial>();
// 颜色数组
TypedArray<godot::Image> imgs;
std::vector<Color> uvparm(MAX_LAYERS, Color(40, 40, 0, 0));
Ref<godot::Image> fallback;
{
PackedByteArray b;
b.resize(LSIZE * LSIZE * 4);
for (int i = 0; i < LSIZE * LSIZE * 4; i += 4) {
b[i] = 90;
b[i + 1] = 110;
b[i + 2] = 70;
b[i + 3] = 255;
}
fallback = godot::Image::create_from_data(LSIZE, LSIZE, false, godot::Image::FORMAT_RGBA8, b);
fallback->generate_mipmaps();
}
for (int i = 0; i < n; ++i) {
const fmt::SplatLayer &L = splat.layers[i];
Ref<godot::Image> img;
if (L.layer >= 1 && L.layer <= (int)tset.layers.size()) {
const fmt::TextureLayer &tl = tset.layers[L.layer - 1];
std::string rp = res.resolve(tl.texture, nullptr);
if (!rp.empty())
img = layer_image(rp, LSIZE, decoded_sources);
// 原客户端 TextureSet.cpp:185:u' = (TexCoordBase*UScale)*vtx_cm + UOffset,
// TexCoordBase = 1/(PATCH_XSIZE*CELLSCALE) = 1/3200;区块归一化 UV -> 平铺频率 = 8*Scale。
float us = tl.u_scale > 0.01f ? tl.u_scale : 1.0f;
float vs = tl.v_scale > 0.01f ? tl.v_scale : 1.0f;
uvparm[i] = Color(8.0f * us, -8.0f * vs, tl.u_offset, -tl.v_offset);
}
imgs.push_back(img.is_valid() ? img : fallback);
}
const auto images_done = now();
Ref<Texture2DArray> arr;
arr.instantiate();
arr->create_from_images(imgs);
const auto array_done = now();
// 权重贴图:ceil(n/4) 张 RGBA8(每通道一层 alpha)
Ref<ImageTexture> wtex[4];
for (int g = 0; g < 4; ++g) {
std::vector<const fmt::SplatLayer *> grp(4, nullptr);
for (int k = 0; k < 4; ++k) {
int li = g * 4 + k;
if (li < n)
grp[k] = &splat.layers[li];
}
wtex[g] = weight_tex(grp);
}
const auto weights_done = now();
Ref<ShaderMaterial> mat;
mat.instantiate();
mat->set_shader(terrain_shader());
mat->set_shader_parameter("layers", arr);
mat->set_shader_parameter("weights0", wtex[0]);
mat->set_shader_parameter("weights1", wtex[1]);
mat->set_shader_parameter("weights2", wtex[2]);
mat->set_shader_parameter("weights3", wtex[3]);
mat->set_shader_parameter("layer_count", n);
{
Array uva;
for (int i = 0; i < MAX_LAYERS; ++i)
uva.push_back(Plane(uvparm[i].r, uvparm[i].g, uvparm[i].b, uvparm[i].a));
mat->set_shader_parameter("layer_uv", uva);
}
const auto shader_done = now();
if (!shadowmap_path.is_empty()) {
mtgodot::Image sm = mtgodot::dds_from_file(shadowmap_path);
if (sm.ok()) {
PackedByteArray b;
b.resize((int64_t)sm.rgba.size());
std::copy(sm.rgba.begin(), sm.rgba.end(), b.ptrw());
Ref<godot::Image> smi = godot::Image::create_from_data(
sm.w, sm.h, false, godot::Image::FORMAT_RGBA8, b);
mat->set_shader_parameter("shadowmap", ImageTexture::create_from_image(smi));
mat->set_shader_parameter("use_shadowmap", true);
}
}
if (profile) {
const auto ms = [](auto a, auto b) { return std::chrono::duration<double, std::milli>(b - a).count(); };
std::fprintf(stderr, "MATERIAL_PROFILE dimensions=%.3f images=%.3f array=%.3f weights=%.3f shader=%.3f shadow=%.3f total=%.3f layers=%d size=%d\n",
ms(start, dimensions_done), ms(dimensions_done, images_done), ms(images_done, array_done),
ms(array_done, weights_done), ms(weights_done, shader_done), ms(shader_done, now()),
ms(start, now()), n, LSIZE);
}
return mat;
}
} // namespace mtgodot
-32
View File
@@ -1,32 +0,0 @@
#pragma once
#include <godot_cpp/classes/image.hpp>
#include <godot_cpp/classes/ref.hpp>
#include <godot_cpp/classes/shader_material.hpp>
#include <godot_cpp/variant/string.hpp>
#include <splat.h>
#include <texture_set.h>
namespace fmt {
struct AssetResolver;
}
namespace mtgodot {
// W2 —— 真正的多图层地表材质。SHINSOO §9-W2。
// - 每图层的 258→256 alpha 打进 RGBA8 权重贴图(≤2 张 = ≤8 图层)
// - 每图层的颜色 DDS 解码 + resize 256²,堆成 Texture2DArray
// - ShaderMaterial:逐图层按权重 mix,UV 按 TextureLayer.u_scale/offset 平铺
// - shadowmap.dds 作为 albedo 乘法项(有则)
// 光照交给 Godot 内置 DirectionalLight(W5 由 .msenv 驱动)。
godot::Ref<godot::ShaderMaterial> build_chunk_terrain_material(
const fmt::SplatSet &splat,
const fmt::TextureSet &tset,
const fmt::AssetResolver &res,
const godot::String &shadowmap_path);
// 清理 function-static 的 terrain shader(退出时调,避免 "shader never freed")。
void cleanup_terrain_shader();
} // namespace mtgodot
-76
View File
@@ -1,76 +0,0 @@
#include "texture_util.h"
#include <cstdlib>
#include <cstring>
#include <godot_cpp/classes/os.hpp>
#include <godot_cpp/core/class_db.hpp>
#include <godot_cpp/variant/packed_byte_array.hpp>
#include <godot_cpp/variant/utility_functions.hpp>
using namespace godot;
namespace mtgodot {
namespace {
int g_state = -1; // -1 = uninit, 0 = off, 1 = on
bool g_warned = false;
void lazy_init() {
if (g_state != -1) {
return;
}
if (const char *e = std::getenv("MTGODOT_TEXCOMP")) {
g_state = (e[0] == '1') ? 1 : 0;
return;
}
// No explicit override: on for mobile targets, off for desktop (keeps the
// Phase-1 parity path byte-identical).
OS *os = OS::get_singleton();
g_state = (os && os->has_feature("mobile")) ? 1 : 0;
}
} // namespace
bool texcomp_enabled() {
lazy_init();
return g_state == 1;
}
void texcomp_set_enabled(bool on) {
g_state = on ? 1 : 0;
}
Ref<ImageTexture> make_color_texture(int w, int h, const uint8_t *rgba, size_t len,
bool mipmaps, TexUse use) {
if (w <= 0 || h <= 0 || rgba == nullptr || len < size_t(w) * size_t(h) * 4) {
return Ref<ImageTexture>();
}
PackedByteArray bytes;
bytes.resize(int64_t(w) * h * 4);
std::memcpy(bytes.ptrw(), rgba, size_t(w) * size_t(h) * 4);
Ref<Image> img = Image::create_from_data(w, h, false, Image::FORMAT_RGBA8, bytes);
if (img.is_null()) {
return Ref<ImageTexture>();
}
if (mipmaps) {
img->generate_mipmaps();
}
if (use == TexUse::COLOR && texcomp_enabled()) {
// ASTC 8x8: ~2 bpp vs 32 for RGBA8. GENERIC source hint (sRGB colour).
Error err = img->compress(Image::COMPRESS_ASTC, Image::COMPRESS_SOURCE_GENERIC,
Image::ASTC_FORMAT_8x8);
if (err != OK && !g_warned) {
g_warned = true;
UtilityFunctions::push_warning(
"mtgodot: runtime ASTC compression unavailable, using RGBA8");
}
}
return ImageTexture::create_from_image(img);
}
} // namespace mtgodot
-34
View File
@@ -1,34 +0,0 @@
// texture_util —— 统一的「RGBA8 字节 -> ImageTexture」出口,可选 GPU 压缩。
//
// 桌面默认关(与旧路径逐字节一致:create_from_data + generate_mipmaps +
// create_from_image)。移动端(OS.has_feature("mobile"))或 MTGODOT_TEXCOMP=1
// 时,对**颜色贴图**做运行时 ASTC 8x8 压缩,省 ~6–8x 显存/带宽;编码失败自动
// 回退 RGBA8。控制图 / 阴影图 / splat / 法线数据图不要走这个(用 RGBA8)。
//
// BACKLOG F4 / docs/PLATFORMS.md。
#pragma once
#include <cstddef>
#include <cstdint>
#include <godot_cpp/classes/image_texture.hpp>
#include <godot_cpp/classes/image.hpp>
#include <godot_cpp/classes/ref.hpp>
namespace mtgodot {
// 颜色贴图(sRGB 视觉内容,可压)。法线/数据图另说,暂不压。
enum class TexUse { COLOR };
// 运行时压缩开关。首次调用惰性初始化:MTGODOT_TEXCOMP 环境变量优先
// ("1"/"0"),否则 OS.has_feature("mobile")。也可显式覆盖。
bool texcomp_enabled();
void texcomp_set_enabled(bool on);
// w*h*4 的 level-0 RGBA8 -> ImageTexture。mipmaps=true 时先生成 mip 链
// (ASTC 压缩前必须)。压缩仅在 texcomp_enabled() && use==COLOR 时发生。
godot::Ref<godot::ImageTexture> make_color_texture(
int w, int h, const uint8_t *rgba, size_t len, bool mipmaps,
TexUse use = TexUse::COLOR);
} // namespace mtgodot
-542
View File
@@ -1,542 +0,0 @@
#include "tree_placeholder.h"
#include "asset_io.h"
#include "dxt.h"
#include "texture_util.h"
#include <asset_resolver.h>
#include <spt.h>
#include <godot_cpp/classes/image.hpp>
#include <godot_cpp/classes/image_texture.hpp>
#include <godot_cpp/classes/file_access.hpp>
#include <godot_cpp/classes/json.hpp>
#include <godot_cpp/classes/gltf_document.hpp>
#include <godot_cpp/classes/gltf_state.hpp>
#include <godot_cpp/classes/gltf_mesh.hpp>
#include <godot_cpp/classes/importer_mesh.hpp>
#include <godot_cpp/variant/utility_functions.hpp>
#include <godot_cpp/classes/shader.hpp>
#include <godot_cpp/classes/shader_material.hpp>
#include <godot_cpp/classes/standard_material3d.hpp>
#include <godot_cpp/variant/packed_byte_array.hpp>
#include <godot_cpp/variant/packed_int32_array.hpp>
#include <godot_cpp/variant/packed_vector2_array.hpp>
#include <godot_cpp/variant/packed_vector3_array.hpp>
#include <algorithm>
#include <cctype>
#include <cmath>
#include <cstdint>
#include <unordered_map>
#include <vector>
using namespace godot;
namespace mtgodot {
namespace {
constexpr float kPI = 3.14159265358979323846f;
constexpr float kTAU = 2.0f * kPI;
// SpeedTree 2 的叶片是中心点 + leaf-cluster table,在顶点 shader 中展开。
// proxy 已在 CPU 侧把叶簇展开成 card;这里只保留 alpha-test 和轻微、按实例错相的风摆。
const char *SRC_LEAF = R"(shader_type spatial;
render_mode cull_disabled, diffuse_lambert, specular_disabled, depth_prepass_alpha;
uniform sampler2D leaf_tex : source_color, filter_linear_mipmap_anisotropic;
uniform float wind_strength = 1.0;
void vertex() {
vec3 wp = (MODEL_MATRIX * vec4(0.0, 0.0, 0.0, 1.0)).xyz;
float ph = wp.x * 0.11 + wp.z * 0.13;
float h = max(VERTEX.y, 0.0);
VERTEX.x += sin(TIME * 1.3 + ph) * 0.025 * wind_strength * h;
VERTEX.z += cos(TIME * 1.05 + ph) * 0.018 * wind_strength * h;
}
void fragment() {
vec4 c = texture(leaf_tex, UV);
if (c.a < 0.38) { discard; }
ALBEDO = c.rgb;
ROUGHNESS = 1.0;
}
)";
Ref<Shader> g_leaf_shader;
Ref<ImageTexture> g_fallback_broadleaf;
Ref<ImageTexture> g_fallback_conifer;
std::unordered_map<std::string, Ref<ImageTexture>> g_tree_texture_cache;
std::unordered_map<std::string, Ref<ArrayMesh>> g_tree_mesh_cache;
Ref<Shader> leaf_shader() {
if (g_leaf_shader.is_null()) {
g_leaf_shader.instantiate();
g_leaf_shader->set_code(SRC_LEAF);
}
return g_leaf_shader;
}
Ref<ImageTexture> fallback_leaf_texture(bool conifer) {
Ref<ImageTexture> &cached = conifer ? g_fallback_conifer : g_fallback_broadleaf;
if (cached.is_valid())
return cached;
const int N = 96;
PackedByteArray b;
b.resize(N * N * 4);
for (int y = 0; y < N; ++y) {
for (int x = 0; x < N; ++x) {
const float u = (x + 0.5f) / N * 2.0f - 1.0f;
const float v = (y + 0.5f) / N * 2.0f - 1.0f;
const float d = std::sqrt(u * u + v * v);
float a = 1.0f - d;
a = a <= 0 ? 0.0f : a * a * (3.0f - 2.0f * a);
const float n = 0.5f + 0.5f * std::sin(x * 0.9f) * std::sin(y * 0.7f);
const float g = conifer ? (0.28f + 0.14f * n) : (0.40f + 0.16f * n);
const float r = conifer ? (0.11f + 0.06f * n) : (0.18f + 0.10f * n);
const float bl = 0.10f + 0.06f * n;
const int o = (y * N + x) * 4;
b[o + 0] = uint8_t(std::min(255.0f, r * 255.0f));
b[o + 1] = uint8_t(std::min(255.0f, g * 255.0f));
b[o + 2] = uint8_t(std::min(255.0f, bl * 255.0f));
b[o + 3] = uint8_t(std::min(255.0f, a * 255.0f));
}
}
Ref<godot::Image> img =
godot::Image::create_from_data(N, N, false, godot::Image::FORMAT_RGBA8, b);
img->generate_mipmaps();
cached = ImageTexture::create_from_image(img);
return cached;
}
Ref<ImageTexture> load_dds_texture(const std::string &path) {
if (path.empty())
return Ref<ImageTexture>();
auto found = g_tree_texture_cache.find(path);
if (found != g_tree_texture_cache.end())
return found->second;
Ref<ImageTexture> result;
mtgodot::Image d = mtgodot::dds_from_file(godot::String(path.c_str()));
if (d.ok()) {
// Bark / leaf-composite albedo (sRGB) -> mobile ASTC via
// make_color_texture (no-op on desktop; keeps mipmaps). §F4.
result = mtgodot::make_color_texture(d.w, d.h, d.rgba.data(), d.rgba.size(),
/*mipmaps=*/true);
}
g_tree_texture_cache.emplace(path, result);
return result;
}
std::string lower(std::string s) {
std::transform(s.begin(), s.end(), s.begin(),
[](unsigned char c) { return (char)std::tolower(c); });
return s;
}
std::string basename(std::string path) {
for (char &c : path)
if (c == '\\') c = '/';
const size_t slash = path.find_last_of('/');
return slash == std::string::npos ? path : path.substr(slash + 1);
}
std::string as_dds(std::string path) {
const size_t dot = path.find_last_of('.');
if (dot != std::string::npos)
path.resize(dot);
return path + ".dds";
}
std::string resolve_sibling(const std::string &treefile, const std::string &texture,
const fmt::AssetResolver &resolver) {
if (texture.empty())
return "";
std::string parent = fmt::AssetResolver::normalize(treefile);
const size_t slash = parent.find_last_of('/');
if (slash != std::string::npos)
parent.resize(slash);
else
parent.clear();
const std::string name = as_dds(basename(texture));
return resolver.resolve(parent.empty() ? name : parent + "/" + name, nullptr);
}
struct TreeTextures {
Ref<ImageTexture> bark;
Ref<ImageTexture> composite;
std::string composite_name;
};
TreeTextures resolve_tree_textures(const std::string &treefile,
const fmt::AssetResolver &resolver) {
TreeTextures out;
const std::string spt_path = resolver.resolve(treefile, nullptr);
if (spt_path.empty())
return out;
fmt::SptInfo info;
if (!fmt::sniff_spt_file(spt_path, info))
return out;
std::string branch;
for (const std::string &ref : info.texture_refs) {
if (lower(ref).find("bark") != std::string::npos) {
branch = ref;
break;
}
}
if (branch.empty() && !info.texture_refs.empty())
branch = info.texture_refs.front();
out.bark = load_dds_texture(resolve_sibling(treefile, branch, resolver));
out.composite_name = info.composite_texture;
out.composite = load_dds_texture(
resolve_sibling(treefile, info.composite_texture, resolver));
return out;
}
struct UVRect {
float u0 = 0, v0 = 0, u1 = 1, v1 = 1;
};
std::vector<UVRect> foliage_rects(const std::string &species,
const std::string &composite, bool atlas) {
if (!atlas)
return {{0, 0, 1, 1}};
// SPT leaf-cluster UV 尚未导出;这些区域只选择各 composite atlas 中的真实叶簇,
// 不声称复原了具体树种的原始 UV。Windows exporter 接入后删除这组 proxy 布局。
const std::string s = lower(species);
const std::string c = lower(composite);
const bool fall = s.find("fall") != std::string::npos;
const bool winter = s.find("winter") != std::string::npos;
if (c.find("b1") != std::string::npos) {
if (fall) return {{0.00f, 0.05f, 0.25f, 0.25f}, {0.25f, 0.25f, 0.50f, 0.50f}};
return {{0.25f, 0.02f, 0.50f, 0.23f}, {0.25f, 0.18f, 0.50f, 0.36f},
{0.00f, 0.27f, 0.27f, 0.49f}};
}
if (c.find("b2") != std::string::npos) {
if (fall) return {{0.00f, 0.00f, 0.25f, 0.25f}, {0.25f, 0.25f, 0.50f, 0.50f}};
return {{0.50f, 0.38f, 0.75f, 0.63f}, {0.50f, 0.63f, 0.75f, 0.88f},
{0.00f, 0.38f, 0.25f, 0.62f}};
}
if (c.find("b3") != std::string::npos) {
if (fall) return {{0.25f, 0.25f, 0.50f, 0.50f}};
return {{0.00f, 0.25f, 0.25f, 0.50f}, {0.00f, 0.50f, 0.25f, 0.75f},
{0.25f, 0.50f, 0.50f, 0.75f}};
}
if (c.find("n1") != std::string::npos) {
if (winter) return {{0.00f, 0.36f, 0.50f, 0.58f}, {0.25f, 0.55f, 0.52f, 0.75f}};
return {{0.00f, 0.72f, 0.28f, 0.96f}, {0.25f, 0.74f, 0.53f, 0.97f}};
}
if (c.find("n2") != std::string::npos) {
return {{0.00f, 0.48f, 0.27f, 0.75f}, {0.26f, 0.73f, 0.58f, 1.00f},
{0.75f, 0.48f, 1.00f, 0.80f}};
}
return {{0, 0, 1, 1}};
}
struct Buf {
PackedVector3Array v, n;
PackedVector2Array uv;
PackedInt32Array idx;
void quad(const Vector3 &a, const Vector3 &b, const Vector3 &c, const Vector3 &d,
const UVRect &r, bool flip_u = false) {
const int base = v.size();
const Vector3 nn = (b - a).cross(d - a).normalized();
v.push_back(a);
v.push_back(b);
v.push_back(c);
v.push_back(d);
for (int i = 0; i < 4; ++i)
n.push_back(nn);
const float l = flip_u ? r.u1 : r.u0;
const float rr = flip_u ? r.u0 : r.u1;
uv.push_back(Vector2(l, r.v1));
uv.push_back(Vector2(rr, r.v1));
uv.push_back(Vector2(rr, r.v0));
uv.push_back(Vector2(l, r.v0));
idx.push_back(base);
idx.push_back(base + 1);
idx.push_back(base + 2);
idx.push_back(base);
idx.push_back(base + 2);
idx.push_back(base + 3);
}
void tube_quad(const Vector3 &b0, const Vector3 &b1, const Vector3 &t1,
const Vector3 &t0, const Vector3 &n0, const Vector3 &n1,
float u0, float u1, float v0, float v1) {
const int base = v.size();
v.push_back(b0);
v.push_back(b1);
v.push_back(t1);
v.push_back(t0);
n.push_back(n0);
n.push_back(n1);
n.push_back(n1);
n.push_back(n0);
uv.push_back(Vector2(u0, v0));
uv.push_back(Vector2(u1, v0));
uv.push_back(Vector2(u1, v1));
uv.push_back(Vector2(u0, v1));
idx.push_back(base);
idx.push_back(base + 2);
idx.push_back(base + 1);
idx.push_back(base);
idx.push_back(base + 3);
idx.push_back(base + 2);
}
Array arrays() const {
Array a;
a.resize(Mesh::ARRAY_MAX);
a[Mesh::ARRAY_VERTEX] = v;
a[Mesh::ARRAY_NORMAL] = n;
a[Mesh::ARRAY_TEX_UV] = uv;
a[Mesh::ARRAY_INDEX] = idx;
return a;
}
};
void tube(Buf &m, const Vector3 &from, const Vector3 &to,
float r0, float r1, int seg, float bark_repeat = 1.0f) {
const Vector3 axis = (to - from).normalized();
if (axis.length_squared() < 0.5f)
return;
const Vector3 helper = std::fabs(axis.y) > 0.9f ? Vector3(1, 0, 0) : Vector3(0, 1, 0);
const Vector3 u = axis.cross(helper).normalized();
const Vector3 w = axis.cross(u).normalized();
for (int i = 0; i < seg; ++i) {
const float a0 = float(i) / seg * kTAU;
const float a1 = float(i + 1) / seg * kTAU;
const Vector3 n0 = u * std::cos(a0) + w * std::sin(a0);
const Vector3 n1 = u * std::cos(a1) + w * std::sin(a1);
m.tube_quad(from + n0 * r0, from + n1 * r0, to + n1 * r1, to + n0 * r1,
n0, n1, float(i) / seg, float(i + 1) / seg, bark_repeat, 0.0f);
}
}
uint32_t hash32(uint32_t s) {
s ^= s >> 16;
s *= 0x7feb352dU;
s ^= s >> 15;
s *= 0x846ca68bU;
s ^= s >> 16;
return s;
}
float hash01(uint32_t s) {
return float(hash32(s) & 0x00FFFFFFU) / float(0x01000000U);
}
uint32_t species_seed(const std::string &s) {
uint32_t h = 2166136261U;
for (unsigned char c : s) {
h ^= c;
h *= 16777619U;
}
return h;
}
bool species_is_palm(const std::string &hint) {
const std::string h = lower(hint);
static const char *kw[] = {"palm", "banana", "aloe", "fern", "joshua"};
for (const char *k : kw)
if (h.find(k) != std::string::npos)
return true;
return false;
}
void add_branches(Buf &wood, const std::string &species, float H, bool conifer, bool palm) {
const float trunk_top = H * (palm ? 0.82f : (conifer ? 0.90f : 0.76f));
const float trunk_r = H * (palm ? 0.028f : 0.035f);
tube(wood, Vector3(0, 0, 0), Vector3(0, trunk_top, 0),
trunk_r * 1.35f, trunk_r * 0.42f, 9, H * 0.22f);
if (palm)
return;
const int count = conifer ? 9 : 8;
const uint32_t seed = species_seed(species);
for (int i = 0; i < count; ++i) {
const float f = (i + 1.0f) / (count + 1.0f);
const float y = H * (conifer ? (0.28f + f * 0.52f) : (0.32f + f * 0.34f));
const float angle = kTAU * (f * 1.6180339f + hash01(seed + i * 17U));
const float len = H * (conifer ? (0.24f * (1.0f - f * 0.55f)) :
(0.18f + 0.08f * hash01(seed + i * 29U)));
const Vector3 from(0, y, 0);
const Vector3 to(std::cos(angle) * len,
y + H * (conifer ? 0.06f : (0.10f + 0.06f * hash01(seed + i * 31U))),
std::sin(angle) * len);
tube(wood, from, to, trunk_r * (0.55f - 0.20f * f), trunk_r * 0.12f, 6,
H * 0.08f);
if (!conifer && (i % 2 == 0)) {
const float side = angle + (hash01(seed + i * 37U) > 0.5f ? 0.65f : -0.65f);
const Vector3 tip = to + Vector3(std::cos(side), 0.65f, std::sin(side)) * (len * 0.42f);
tube(wood, to, tip, trunk_r * 0.16f, trunk_r * 0.05f, 5, H * 0.04f);
}
}
}
void add_leaf_cards(Buf &leaves, const std::string &species, float H,
bool conifer, bool palm, const std::vector<UVRect> &rects) {
const uint32_t seed = species_seed(species);
const int count = palm ? 16 : (conifer ? 24 : 24);
for (int i = 0; i < count; ++i) {
const float a = kTAU * (float(i) * 0.6180339f + hash01(seed + i * 101U) * 0.15f);
Vector3 center;
float width = 1.0f, height = 1.0f;
if (palm) {
const float radial = H * (0.10f + 0.18f * hash01(seed + i * 103U));
center = Vector3(std::cos(a) * radial, H * (0.78f + 0.12f * hash01(seed + i * 107U)),
std::sin(a) * radial);
width = H * 0.32f;
height = H * 0.18f;
} else if (conifer) {
const float yf = 0.30f + 0.62f * (float(i) + 0.5f) / count;
const float radial = H * 0.23f * (1.0f - yf * 0.70f) *
(0.35f + 0.65f * hash01(seed + i * 109U));
center = Vector3(std::cos(a) * radial, H * yf, std::sin(a) * radial);
width = H * (0.18f + 0.10f * (1.0f - yf));
height = H * 0.18f;
} else {
const float yf = hash01(seed + i * 109U);
const float yn = yf * 2.0f - 1.0f;
const float radial = H * 0.34f * std::sqrt(std::max(0.05f, 1.0f - yn * yn)) *
(0.25f + 0.75f * std::sqrt(hash01(seed + i * 113U)));
center = Vector3(std::cos(a) * radial, H * (0.58f + yf * 0.34f),
std::sin(a) * radial);
width = H * (0.23f + 0.10f * hash01(seed + i * 127U));
height = H * (0.15f + 0.08f * hash01(seed + i * 131U));
}
const Vector3 right(std::cos(a + kPI * 0.5f), 0, std::sin(a + kPI * 0.5f));
const Vector3 up(0, 1, 0);
const UVRect &uv = rects[size_t(i) % rects.size()];
auto card = [&](const Vector3 &r, bool flip) {
leaves.quad(center - r * (width * 0.5f) - up * (height * 0.5f),
center + r * (width * 0.5f) - up * (height * 0.5f),
center + r * (width * 0.5f) + up * (height * 0.5f),
center - r * (width * 0.5f) + up * (height * 0.5f), uv, flip);
};
card(right, (i & 1) != 0);
// 原 SpeedTree leaf cluster 始终面向相机;静态 proxy 用交叉 card 保证任意视角
// 都不会只看到一条边。离线 exporter 接入后由真实 leaf table 替代。
const Vector3 crossed(std::cos(a), 0, std::sin(a));
card(crossed, (i & 1) == 0);
}
}
Ref<ArrayMesh> build_proxy_impl(const std::string &species, float height_m,
const TreeTextures &textures) {
const String hint(species.c_str());
const bool conifer = species_is_conifer(hint);
const bool palm = species_is_palm(species);
const float H = std::max(2.0f, height_m);
const bool atlas = textures.composite.is_valid();
const std::vector<UVRect> rects = foliage_rects(species, textures.composite_name, atlas);
Buf wood, leaves;
add_branches(wood, species, H, conifer, palm);
add_leaf_cards(leaves, species, H, conifer, palm, rects);
Ref<ArrayMesh> mesh;
mesh.instantiate();
mesh->add_surface_from_arrays(Mesh::PRIMITIVE_TRIANGLES, wood.arrays());
mesh->add_surface_from_arrays(Mesh::PRIMITIVE_TRIANGLES, leaves.arrays());
Ref<StandardMaterial3D> bark;
bark.instantiate();
bark->set_albedo(textures.bark.is_valid() ? Color(1, 1, 1) : Color(0.30f, 0.21f, 0.13f));
bark->set_roughness(1.0f);
bark->set_texture_filter(StandardMaterial3D::TEXTURE_FILTER_LINEAR_WITH_MIPMAPS_ANISOTROPIC);
if (textures.bark.is_valid())
bark->set_texture(StandardMaterial3D::TEXTURE_ALBEDO, textures.bark);
mesh->surface_set_material(0, bark);
Ref<ShaderMaterial> leaf;
leaf.instantiate();
leaf->set_shader(leaf_shader());
leaf->set_shader_parameter("leaf_tex",
textures.composite.is_valid() ? textures.composite : fallback_leaf_texture(conifer));
// MapUtil.cpp initializes fWindStrength to 0.2 and fWindRandom to 0;
// Environment_Load does not read a wind token from .msenv.
leaf->set_shader_parameter("wind_strength", 0.2f);
mesh->surface_set_material(1, leaf);
return mesh;
}
} // namespace
void cleanup_tree_shader() {
g_tree_mesh_cache.clear();
g_tree_texture_cache.clear();
g_fallback_broadleaf.unref();
g_fallback_conifer.unref();
g_leaf_shader.unref();
}
bool species_is_conifer(const String &hint) {
const String h = hint.to_lower();
static const char *kw[] = {"cedar", "cypress", "pine", "fir", "spruce", "conifer", "juniper",
"christmastree"};
for (const char *k : kw)
if (h.find(k) != -1)
return true;
return false;
}
Ref<ArrayMesh> build_placeholder_tree(const String &species_hint, float height_m) {
TreeTextures empty;
return build_proxy_impl(std::string(species_hint.utf8().get_data()), height_m, empty);
}
static Ref<ArrayMesh> load_native_tree(const std::string &treefile, const fmt::AssetResolver &resolver) {
const String source(resolver.resolve(treefile, nullptr).c_str());
if (source.is_empty()) return {};
const String directory = String(resolver.assets_root.c_str()).path_join("TreeGeometry");
const String manifest_path = directory.path_join("manifest.json");
if (!FileAccess::file_exists(manifest_path)) return {};
const Variant parsed = JSON::parse_string(FileAccess::get_file_as_string(manifest_path));
if (parsed.get_type() != Variant::DICTIONARY) return {};
const Dictionary manifest = parsed;
if (int(manifest.get("schema_version", 0)) != 1 || String(manifest.get("coordinates", "")) != "godot_y_up_meters") return {};
const Dictionary trees = manifest.get("trees", Dictionary());
const String source_hash = FileAccess::get_sha256(source);
if (!trees.has(source_hash)) return {};
const Dictionary record = trees[source_hash];
const String filename = record.get("glb", "");
if (filename != source_hash + String(".glb")) return {};
const String path = directory.path_join(filename);
if (!FileAccess::file_exists(path) || FileAccess::get_sha256(path) != String(record.get("glb_sha256", ""))) return {};
Ref<GLTFDocument> document;
document.instantiate();
Ref<GLTFState> state;
state.instantiate();
if (document->append_from_file(path, state) != OK || state->get_meshes().size() != 1) return {};
Ref<GLTFMesh> gltf_mesh = state->get_meshes()[0];
if (gltf_mesh.is_null() || gltf_mesh->get_mesh().is_null()) return {};
Ref<ArrayMesh> mesh = gltf_mesh->get_mesh()->get_mesh();
if (mesh.is_null() || mesh->get_surface_count() == 0) return {};
mesh->set_meta("tree_geometry", "native_spt");
mesh->set_meta("source_sha256", source_hash);
mesh->set_meta("glb_sha256", record.get("glb_sha256", ""));
mesh->set_meta("leaf_mode", "static_extracted_cards");
// GLB vertices already use Godot metres. Never impose the proxy's 12m height.
return mesh;
}
Ref<ArrayMesh> get_tree_mesh(const std::string &treefile,
const fmt::AssetResolver &resolver, float height_m) {
const std::string key = resolver.assets_root + "|" + fmt::AssetResolver::normalize(treefile) +
"#" + std::to_string(height_m);
auto found = g_tree_mesh_cache.find(key);
if (found != g_tree_mesh_cache.end())
return found->second;
Ref<ArrayMesh> native = load_native_tree(treefile, resolver);
if (native.is_valid()) {
g_tree_mesh_cache.emplace(key, native);
return native;
}
UtilityFunctions::push_warning(String("Tree native geometry unavailable or hash mismatch; proxy: ") + String(treefile.c_str()));
const TreeTextures textures = resolve_tree_textures(treefile, resolver);
Ref<ArrayMesh> mesh = build_proxy_impl(treefile, height_m, textures);
mesh->set_meta("tree_geometry", "proxy");
g_tree_mesh_cache.emplace(key, mesh);
return mesh;
}
} // namespace mtgodot
-35
View File
@@ -1,35 +0,0 @@
#pragma once
#include <godot_cpp/classes/array_mesh.hpp>
#include <godot_cpp/classes/ref.hpp>
#include <godot_cpp/variant/string.hpp>
#include <string>
namespace fmt {
struct AssetResolver;
}
// Native trees use verified offline GLBs; `.spt` is never linked to a proprietary
// runtime on macOS. Deterministic procedural geometry remains a reported fallback.
// 一份共享 mesh / treefile,逐实例只由 MultiMesh 承载原 AreaData 位置。
namespace mtgodot {
// species_hint = treefile 名(判针叶/阔叶)。height_m ≈ 期望树高(米)。
godot::Ref<godot::ArrayMesh> build_placeholder_tree(const godot::String &species_hint,
float height_m = 12.0f);
// Cached per treefile for MultiMesh use. Missing or mismatched native resources
// use a proxy with explicit metadata and a warning; package acceptance rejects it.
// Prefer hash-matched native GLB geometry in assets/TreeGeometry. The procedural
// implementation is an explicitly reported fallback for missing/invalid trees.
godot::Ref<godot::ArrayMesh> get_tree_mesh(const std::string &treefile,
const fmt::AssetResolver &resolver, float height_m = 12.0f);
// 从 treefile 名猜是否针叶(cedar / cypress / pine / fir / spruce…)。
bool species_is_conifer(const godot::String &species_hint);
// 退出时清 shader、DDS 和 treefile->mesh 缓存。
void cleanup_tree_shader();
} // namespace mtgodot
-218
View File
@@ -1,218 +0,0 @@
#include "water_builder.h"
#include "water_motion.h"
#include "asset_io.h"
#include "dxt.h"
#include <godot_cpp/classes/image.hpp>
#include <godot_cpp/classes/shader.hpp>
#include <godot_cpp/classes/shader_material.hpp>
#include <godot_cpp/classes/texture2d_array.hpp>
#include <godot_cpp/classes/time.hpp>
#include <godot_cpp/variant/utility_functions.hpp>
#include <godot_cpp/variant/packed_byte_array.hpp>
#include <godot_cpp/variant/packed_color_array.hpp>
#include <godot_cpp/variant/packed_int32_array.hpp>
#include <godot_cpp/variant/packed_vector2_array.hpp>
#include <godot_cpp/variant/packed_vector3_array.hpp>
#include <godot_cpp/variant/typed_array.hpp>
#include <asset_resolver.h>
#include <m2_coord.h>
#include <terrain_mesh.h> // terrain_height_at
#include <algorithm>
using namespace godot;
namespace mtgodot {
namespace {
// 30 帧序列 + 逐顶点水深 alpha(顶点 COLOR.a) + 轻微高度浮动。
// UV = 世界米;平铺频率在 shader 里按 1/(CELLSCALE*4 cm) = 1/8m。
const char *SRC_WATER = R"(shader_type spatial;
render_mode blend_mix, cull_disabled, depth_draw_never, unshaded;
uniform sampler2DArray frames : source_color, filter_linear_mipmap, repeat_enable;
uniform float uv_per_meter = 0.125; // 1/8m,= 原客户端 1/(CELLSCALE*4)
uniform float height_offset = 0.0; // Shared reference water translation, metres.
void vertex() {
VERTEX.y += height_offset;
}
void fragment() {
int f = int(mod(TIME * 1000.0 / 70.0, 30.0)); // 70ms/帧
vec2 uv = UV * uv_per_meter;
vec3 tex = texture(frames, vec3(uv, float(f))).rgb;
// MapOutdoorWater selects texture RGB and vertex diffuse alpha directly.
// Do not introduce PBR lighting, invented tint or view-dependent opacity.
ALBEDO = tex;
ALPHA = COLOR.a;
}
)";
Ref<Shader> g_water_shader;
Ref<ShaderMaterial> g_water_mat; // 30 帧数组只建一次
WaterMotion g_water_motion;
Ref<ShaderMaterial> water_material(const fmt::AssetResolver &res) {
if (g_water_mat.is_valid())
return g_water_mat;
if (g_water_shader.is_null()) {
g_water_shader.instantiate();
g_water_shader->set_code(SRC_WATER);
}
Ref<ShaderMaterial> m;
m.instantiate();
m->set_shader(g_water_shader);
// special/water/01..30.dds
TypedArray<godot::Image> imgs;
int W = 0, H = 0;
for (int i = 1; i <= 30; ++i) {
char nm[64];
std::snprintf(nm, sizeof(nm), "d:/ymir work/special/water/%02d.dds", i);
std::string rp = res.resolve(nm, nullptr);
mtgodot::Image d = rp.empty() ? mtgodot::Image{} : mtgodot::dds_from_file(godot::String(rp.c_str()));
if (!d.ok())
break;
if (W == 0) {
W = d.w;
H = d.h;
}
PackedByteArray b;
b.resize((int64_t)d.rgba.size());
std::copy(d.rgba.begin(), d.rgba.end(), b.ptrw());
Ref<godot::Image> img =
godot::Image::create_from_data(d.w, d.h, false, godot::Image::FORMAT_RGBA8, b);
if (img.is_valid() && (d.w != W || d.h != H))
img->resize(W, H, godot::Image::INTERPOLATE_BILINEAR);
if (img.is_valid())
img->generate_mipmaps();
imgs.push_back(img);
}
if (imgs.size() == 30) {
Ref<Texture2DArray> arr;
arr.instantiate();
arr->create_from_images(imgs);
m->set_shader_parameter("frames", arr);
}
g_water_mat = m;
return m;
}
} // namespace
void cleanup_water_shader() {
g_water_shader.unref();
g_water_mat.unref();
g_water_motion = WaterMotion{};
}
void update_water_animation() {
if (g_water_mat.is_null()) return;
const uint64_t now = Time::get_singleton()->get_ticks_msec();
uint64_t duration = 1000;
double depth = 0;
if (g_water_motion.initialized && now - g_water_motion.start_ms > g_water_motion.duration_ms) {
duration = UtilityFunctions::randi_range(1000, 3000);
if (g_water_motion.end == 0) depth = UtilityFunctions::randi_range(0, 15);
}
g_water_mat->set_shader_parameter("height_offset", g_water_motion.sample(now, duration, depth));
}
std::vector<WaterPiece> build_chunk_water(const fmt::WaterMap &wm, const fmt::HeightMap &hm,
int tile_x, int tile_y, double height_scale, const fmt::AssetResolver &res) {
std::vector<WaterPiece> out;
if (wm.layer_count == 0 || wm.ids.size() != size_t(fmt::WATERMAP_XY) * fmt::WATERMAP_XY)
return out;
const int W = fmt::WATERMAP_XY; // 128
const double CELL_M = double(fmt::m2coord::CELLSCALE) * fmt::m2coord::CM_TO_M; // 2m/texel
const double X0 = double(tile_x) * fmt::m2coord::CHUNK_CM * fmt::m2coord::CM_TO_M;
const double Z0 = double(tile_y) * fmt::m2coord::CHUNK_CM * fmt::m2coord::CM_TO_M;
auto depth_alpha = [&](int tex_x, int tex_y, double water_h_cm) -> float {
// 水 texel (tex_x,tex_y) 对应的区块本地 cm(texel = 1 格 = CELLSCALE)
double lx = tex_x * double(fmt::m2coord::CELLSCALE);
double ly = tex_y * double(fmt::m2coord::CELLSCALE);
double th = fmt::terrain_height_at(hm, lx, ly, height_scale); // cm
double depth_cm = water_h_cm - th;
// AreaTerrain uses RAW height differences, before HeightScale, with
// OpaqueWaterDepth=400 and clamps at 80% (not a 12% shoreline floor).
return water_depth_alpha(depth_cm, height_scale);
};
for (int layer = 0; layer < wm.layer_count; ++layer) {
double h_cm = double(layer < (int)wm.heights.size() ? wm.heights[layer] : 0) * height_scale;
float gy = float(h_cm * fmt::m2coord::CM_TO_M);
PackedVector3Array v;
PackedVector3Array n;
PackedVector2Array uv;
PackedColorArray col;
PackedInt32Array idx;
for (int y = 0; y < W; ++y) {
int x = 0;
while (x < W) {
if (wm.ids[y * W + x] != layer) {
++x;
continue;
}
int xs = x;
// Keep each cell's four shoreline samples. Merging a whole row
// discarded interior terrain peaks and bridged dry banks with water.
++x;
float aLL = depth_alpha(xs, y, h_cm), aLR = depth_alpha(x, y, h_cm);
float aUR = depth_alpha(x, y + 1, h_cm), aUL = depth_alpha(xs, y + 1, h_cm);
// AreaTerrain skips the quad when every diffuse alpha is zero.
if (aLL == 0 && aLR == 0 && aUR == 0 && aUL == 0)
continue;
double x0 = X0 + xs * CELL_M, x1 = X0 + x * CELL_M;
double z0 = Z0 + y * CELL_M, z1 = Z0 + (y + 1) * CELL_M;
int base = v.size();
v.push_back(Vector3(x0, gy, z0));
v.push_back(Vector3(x1, gy, z0));
v.push_back(Vector3(x1, gy, z1));
v.push_back(Vector3(x0, gy, z1));
for (int k = 0; k < 4; ++k)
n.push_back(Vector3(0, 1, 0));
uv.push_back(Vector2(float(x0), float(z0)));
uv.push_back(Vector2(float(x1), float(z0)));
uv.push_back(Vector2(float(x1), float(z1)));
uv.push_back(Vector2(float(x0), float(z1)));
col.push_back(Color(1, 1, 1, aLL));
col.push_back(Color(1, 1, 1, aLR));
col.push_back(Color(1, 1, 1, aUR));
col.push_back(Color(1, 1, 1, aUL));
idx.push_back(base);
idx.push_back(base + 2);
idx.push_back(base + 1);
idx.push_back(base);
idx.push_back(base + 3);
idx.push_back(base + 2);
}
}
if (v.is_empty())
continue;
Array arr;
arr.resize(Mesh::ARRAY_MAX);
arr[Mesh::ARRAY_VERTEX] = v;
arr[Mesh::ARRAY_NORMAL] = n;
arr[Mesh::ARRAY_TEX_UV] = uv;
arr[Mesh::ARRAY_COLOR] = col;
arr[Mesh::ARRAY_INDEX] = idx;
Ref<godot::ArrayMesh> mesh;
mesh.instantiate();
mesh->add_surface_from_arrays(Mesh::PRIMITIVE_TRIANGLES, arr);
mesh->surface_set_material(0, water_material(res));
out.push_back({mesh, gy});
}
return out;
}
} // namespace mtgodot
-33
View File
@@ -1,33 +0,0 @@
#pragma once
#include "water_depth.h"
#include <godot_cpp/classes/array_mesh.hpp>
#include <godot_cpp/classes/ref.hpp>
#include <terrain_files.h>
#include <vector>
namespace fmt {
struct AssetResolver;
}
// W7 / PARITY §4 —— water.wtr -> 每层水面网格(texel 掩膜)+ 共享水材质。
// 水材质用原客户端资产:`special/water/01..30.dds` 30 帧序列(`MapOutdoorWater.cpp:14/43`,
// 70ms/帧),UV 平铺频率 = 1/(CELLSCALE*4)(每 4 格一循环),逐顶点水深 alpha,轻微高度浮动。
namespace mtgodot {
struct WaterPiece {
godot::Ref<godot::ArrayMesh> mesh; // Godot 空间,已含区块原点
float godot_y = 0;
};
// hm/height_scale 用来算每个水面顶点下方的地形高度 -> 水深 -> alpha。
std::vector<WaterPiece> build_chunk_water(const fmt::WaterMap &wm, const fmt::HeightMap &hm,
int tile_x, int tile_y, double height_scale, const fmt::AssetResolver &res);
void cleanup_water_shader();
void update_water_animation();
} // namespace mtgodot
-12
View File
@@ -1,12 +0,0 @@
#pragma once
#include <cmath>
namespace mtgodot {
// 40250 MapOutdoor.cpp / AreaTerrain.cpp. Arguments in scaled centimetres.
inline float water_depth_alpha(double depth_cm, double height_scale) {
if (!std::isfinite(depth_cm) || !std::isfinite(height_scale) ||
height_scale <= 0.0 || depth_cm <= 0.0) return 0.0f;
double alpha = depth_cm / (400.0 * height_scale);
return static_cast<float>(alpha > 0.8 ? 0.8 : alpha);
}
}
-24
View File
@@ -1,24 +0,0 @@
#pragma once
#include <algorithm>
#include <cstdint>
namespace mtgodot {
// MapOutdoorWater: one shared vertical translation, alternating a random
// 0..-15cm endpoint and zero, linearly interpolated over 1000..3000ms.
struct WaterMotion {
uint64_t start_ms = 0, duration_ms = 300;
double begin = 0, end = 0, current = 0;
bool initialized = false;
double sample(uint64_t now, uint64_t next_duration, double next_depth_cm) {
if (!initialized) { start_ms = now; initialized = true; }
if (now - start_ms > duration_ms) {
begin = current;
end = end == 0 ? -std::clamp(next_depth_cm, 0.0, 15.0) * 0.01 : 0;
start_ms = now;
duration_ms = std::clamp<uint64_t>(next_duration, 1000, 3000);
}
current = begin + (end - begin) * double(now - start_ms) / double(duration_ms);
return current;
}
};
}
+2 -2
View File
@@ -13,8 +13,8 @@ Bytes header(uint32_t bits, uint32_t r, uint32_t g, uint32_t b, uint32_t a, size
put(out, 92, r); put(out, 96, g); put(out, 100, b); put(out, 104, a);
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() {
-17
View File
@@ -1,17 +0,0 @@
#include "water_depth.h"
#include <cassert>
#include <cstdio>
#include <limits>
#include <initializer_list>
int main() {
for (double scale : {0.25, 0.5, 1.0, 2.0}) {
assert(mtgodot::water_depth_alpha(-1, scale) == 0);
assert(mtgodot::water_depth_alpha(0, scale) == 0);
assert(std::abs(mtgodot::water_depth_alpha(100 * scale, scale) - 0.25) < 1e-6);
assert(std::abs(mtgodot::water_depth_alpha(320 * scale, scale) - 0.8) < 1e-6);
assert(std::abs(mtgodot::water_depth_alpha(1000 * scale, scale) - 0.8) < 1e-6);
}
assert(mtgodot::water_depth_alpha(100, 0) == 0);
assert(mtgodot::water_depth_alpha(std::numeric_limits<double>::quiet_NaN(), 1) == 0);
std::puts("PASS: water depth reference (raw depth, shoreline, cap, height scale)");
}
-21
View File
@@ -1,21 +0,0 @@
#include "water_motion.h"
#include <cassert>
#include <cmath>
#include <cstdio>
int main() {
mtgodot::WaterMotion m;
assert(m.sample(100, 2000, 15) == 0);
assert(m.sample(400, 2000, 15) == 0);
assert(m.sample(401, 2000, 15) == 0);
assert(std::abs(m.sample(1401, 2000, 15) + .075) < 1e-9);
assert(std::abs(m.sample(2401, 2000, 15) + .15) < 1e-9);
assert(std::abs(m.sample(2402, 1000, 15) + .15) < 1e-9);
assert(std::abs(m.sample(2902, 1000, 15) + .075) < 1e-9);
assert(m.sample(3402, 1000, 15) == 0);
// Large frame gaps start at the last rendered height, without overshoot.
for (uint64_t t = 3403; t < 200000; t += 137) {
const double y = m.sample(t, 1700, 9);
assert(y <= 0 && y >= -.15);
}
std::puts("PASS: shared linear water motion, endpoints, timing, no overshoot");
}
+5 -5
View File
@@ -1,9 +1,9 @@
# extension/third_party — vendored native dependencies for the GDExtension.
# Vendored native dependencies for the 40250 port and SDL/Vulkan client.
#
# Phase 1 (macOS) used Homebrew for libsodium / libzstd / liblzo2. Those don't
# 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()