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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013EJxkHiNKS4kybHS3XKyAJ
This commit is contained in:
shen
2026-08-31 20:02:12 +09:00
co-authored by Claude Sonnet 5
parent f4917a2b3b
commit 47baf6c0c6
414 changed files with 69568 additions and 385 deletions
+129 -23
View File
@@ -1,41 +1,147 @@
# mtgodot GDExtension — M0' skeleton (registers Metin2Model, links libgr2).
# mtgodot GDExtension — registers Metin2Model / Metin2AnimPlayer, links libgr2.
# libgr2 is added at the top-level CMakeLists (vendored under ../libgr2), so here
# we just consume the xrender::libgr2 target.
# --- godot-cpp (submodule @ master) ---
# master has no per-minor branch; it ships bundled extension_api-4-*.json.
# Pin to 4.7 to match the installed editor (4.7.1; patch-level ABI is compatible).
# --- 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)
# --- libgr2 from the sibling xrender-poc repo ---
# M0': plain sibling-directory reference (both repos live under .../mt/).
# For a portable checkout, either git-submodule xrender-poc or vendor libgr2,
# then pass -DXRENDER_POC_DIR=/path/to/xrender-poc.
set(XRENDER_POC_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../../xrender-poc"
CACHE PATH "Path to the xrender-poc repo (provides libgr2)")
if(NOT EXISTS "${XRENDER_POC_DIR}/libgr2/CMakeLists.txt")
message(FATAL_ERROR
"libgr2 not found at '${XRENDER_POC_DIR}/libgr2'. "
"Pass -DXRENDER_POC_DIR=/path/to/xrender-poc")
endif()
add_subdirectory("${XRENDER_POC_DIR}/libgr2" "${CMAKE_CURRENT_BINARY_DIR}/libgr2")
# --- 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)
# --- the extension shared library ---
add_library(mtgodot SHARED
# --- mtnet: Metin2 net protocol core (no godot-cpp dep; libsodium only) ---
add_library(mtnet STATIC
src/net/secure_cipher.cpp
src/net/net_stream.cpp
src/net/entity_store.cpp
src/net/mark_image.cpp
)
target_include_directories(mtnet PUBLIC src/net)
target_link_libraries(mtnet PUBLIC mt3p::sodium mt3p::minilzo)
target_compile_features(mtnet PUBLIC cxx_std_20)
# --- mtpack: m2dev-fork asset pack reader/writer (libsodium + zstd) ---
add_library(mtpack STATIC
src/pack/eterpack.cpp
src/pack/pack_mount.cpp
src/pack/asset_source.cpp
)
target_include_directories(mtpack PUBLIC src/pack)
target_link_libraries(mtpack PUBLIC mt3p::sodium mt3p::zstd xrender::formats)
target_compile_features(mtpack PUBLIC cxx_std_20)
# --- mtproto: item_proto / mob_proto reader (libsodium + LZO) ---
add_library(mtproto STATIC src/proto/proto.cpp)
target_include_directories(mtproto PUBLIC src/proto)
target_link_libraries(mtproto PUBLIC mt3p::sodium mt3p::minilzo)
target_compile_features(mtproto PUBLIC cxx_std_20)
# Host-only CLIs + tests: they run on the build machine, so skip them entirely
# when cross-compiling the extension for a device.
set(MT_HOST_BUILD FALSE)
if(CMAKE_SYSTEM_NAME STREQUAL CMAKE_HOST_SYSTEM_NAME)
set(MT_HOST_BUILD TRUE)
endif()
if(MT_HOST_BUILD)
add_executable(packtool tools/packtool.cpp)
target_link_libraries(packtool PRIVATE mtpack)
# net_probe: connect to an auth server, run the handshake + CG_LOGIN3, report.
add_executable(net_probe tools/net_probe.cpp)
target_link_libraries(net_probe PRIVATE mtnet)
# net_e2e: full auth -> game -> char list -> select -> PHASE_GAME against a
# real server; dumps entities / points / inventory. Live integration check.
add_executable(net_e2e tools/net_e2e.cpp)
target_link_libraries(net_e2e PRIVATE mtnet)
endif()
if(BUILD_TESTING AND MT_HOST_BUILD)
add_executable(net_cipher_test tests/net_cipher_test.cpp)
target_link_libraries(net_cipher_test PRIVATE mtnet)
add_test(NAME net.cipher_roundtrip COMMAND net_cipher_test)
add_executable(net_loopback_test tests/net_loopback_test.cpp)
target_link_libraries(net_loopback_test PRIVATE mtnet)
add_test(NAME net.loopback_flow COMMAND net_loopback_test)
add_executable(net_entity_test tests/net_entity_test.cpp)
target_link_libraries(net_entity_test PRIVATE mtnet)
add_test(NAME net.entity_store COMMAND net_entity_test)
add_executable(net_mark_test tests/net_mark_test.cpp)
target_link_libraries(net_mark_test PRIVATE mtnet mt3p::minilzo)
add_test(NAME net.guild_mark COMMAND net_mark_test)
add_executable(pack_roundtrip_test tests/pack_roundtrip_test.cpp)
target_link_libraries(pack_roundtrip_test PRIVATE mtpack)
add_test(NAME pack.roundtrip COMMAND pack_roundtrip_test)
add_executable(proto_test tests/proto_test.cpp)
target_link_libraries(proto_test PRIVATE mtproto)
add_test(NAME proto.item_mob COMMAND proto_test)
if(DEFINED ENV{M2_ASSETS})
set_tests_properties(proto.item_mob PROPERTIES ENVIRONMENT "M2_ASSETS=$ENV{M2_ASSETS}")
endif()
endif()
# --- the extension library ---
# macOS/Android: SHARED (Godot dlopen()s it). iOS: STATIC — the platform forbids
# loading dynamic libraries, so the archive is linked into the app at export time
# (see docs/PLATFORMS.md for the remaining F1 export/sign steps).
if(CMAKE_SYSTEM_NAME STREQUAL "iOS")
set(MT_LIB_KIND STATIC)
set(MT_PLAT_TAG "ios")
elseif(CMAKE_SYSTEM_NAME STREQUAL "Android")
set(MT_LIB_KIND SHARED)
set(MT_PLAT_TAG "android")
else()
set(MT_LIB_KIND SHARED)
set(MT_PLAT_TAG "macos")
endif()
add_library(mtgodot ${MT_LIB_KIND}
src/register_types.cpp
src/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
)
target_compile_features(mtgodot PRIVATE cxx_std_20)
target_link_libraries(mtgodot PRIVATE godot::cpp xrender::libgr2)
target_link_libraries(mtgodot PRIVATE godot::cpp xrender::libgr2 xrender::formats mtnet mtproto)
# Drop the dylib straight into the Godot project where the .gdextension expects it:
# project/bin/libmtgodot.macos.template_debug.dylib (Debug)
# project/bin/libmtgodot.macos.template_release.dylib (Release)
# 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.macos.$<IF:$<CONFIG:Release>,template_release,template_debug>"
OUTPUT_NAME "mtgodot.${MT_PLAT_TAG}.${MT_CFG_TAG}${MT_ARCH_SUFFIX}"
ARCHIVE_OUTPUT_DIRECTORY "${CMAKE_SOURCE_DIR}/project/bin"
LIBRARY_OUTPUT_DIRECTORY "${CMAKE_SOURCE_DIR}/project/bin"
)
+40
View File
@@ -0,0 +1,40 @@
#include "asset_io.h"
#include <godot_cpp/classes/file_access.hpp>
using namespace godot;
namespace mtgodot {
PackedByteArray read_file(const String &path) {
if (path.is_empty()) {
return PackedByteArray();
}
// FileAccess::get_file_as_bytes handles res:// / user:// / absolute OS paths.
return FileAccess::get_file_as_bytes(path);
}
bool file_exists(const String &path) {
return !path.is_empty() && FileAccess::file_exists(path);
}
Image dds_from_file(const String &path) {
PackedByteArray b = read_file(path);
if (b.is_empty()) {
return Image{};
}
return load_dds(b.ptr(), (size_t)b.size());
}
std::optional<gr2::File> gr2_from_file(const String &path, gr2::LoadError *err) {
PackedByteArray b = read_file(path);
if (b.is_empty()) {
if (err) {
*err = {"open", std::string("cannot read ") + path.utf8().get_data()};
}
return std::nullopt;
}
return gr2::File::load(b.ptr(), (size_t)b.size(), err);
}
} // namespace mtgodot
+33
View File
@@ -0,0 +1,33 @@
// asset_io — the one portable asset-read point.
//
// The standalone libs (libgr2, formats, mtproto, mtpack) keep their raw
// fopen/ifstream `*_path()` entry points for the non-Godot CTests. Every read
// from the *running extension* goes through here instead, so it works for:
// - res:// (loose files in dev, PCK on iOS/Android read-only bundles)
// - user:// (extracted cache)
// - absolute OS paths (dev: AssetRoot points at mtgodot-poc/assets/)
// godot::FileAccess handles all three transparently.
#pragma once
#include <godot_cpp/variant/packed_byte_array.hpp>
#include <godot_cpp/variant/string.hpp>
#include <gr2/gr2.h>
#include <optional>
#include "dxt.h"
namespace mtgodot {
// Whole-file read. Empty PackedByteArray on failure (path missing / unreadable).
godot::PackedByteArray read_file(const godot::String &path);
bool file_exists(const godot::String &path); // FileAccess::file_exists wrapper
// .dds -> RGBA8 level 0 via read_file. !ok() on failure.
Image dds_from_file(const godot::String &path);
// .gr2 parse via read_file. nullopt on read or parse failure.
std::optional<gr2::File> gr2_from_file(const godot::String &path, gr2::LoadError *err = nullptr);
} // namespace mtgodot
+152
View File
@@ -0,0 +1,152 @@
#include "environment_builder.h"
#include <godot_cpp/classes/environment.hpp>
#include <godot_cpp/classes/procedural_sky_material.hpp>
#include <godot_cpp/classes/sky.hpp>
#include <godot_cpp/core/object.hpp>
#include <m2_coord.h>
#include <algorithm>
using namespace godot;
namespace mtgodot {
namespace {
Color rgba(const fmt::Rgba &c) { return Color(c[0], c[1], c[2], c[3]); }
float luma(const fmt::Rgba &c) { return 0.2126f * c[0] + 0.7152f * c[1] + 0.0722f * c[2]; }
} // namespace
EnvNodes apply_environment(const fmt::Environment &env, Node *parent) {
EnvNodes out;
// --- DirectionalLight (Background) ---
out.sun = Object::cast_to<DirectionalLight3D>(parent->get_node_or_null(NodePath("Sun")));
if (!out.sun) {
out.sun = memnew(DirectionalLight3D);
out.sun->set_name("Sun");
parent->add_child(out.sun);
}
// .msenv Direction 是 Metin2 Z-up 向量(光传播方向)。
fmt::m2coord::Vec3 d = fmt::m2coord::direction_to_godot(
env.dir_light.direction[0], env.dir_light.direction[1], env.dir_light.direction[2]);
Vector3 fwd(d.x, d.y, d.z);
if (fwd.length() < 1e-4f)
fwd = Vector3(-0.4f, -0.7f, -0.55f);
fwd.normalize();
// Godot 光沿自身 -Z 照射 -> basis 的 -Z = fwd
Basis b = Basis::looking_at(fwd, Vector3(0, 1, 0));
out.sun->set_transform(Transform3D(b, Vector3(0, 0, 0)));
if (env.dir_light.bg_enable || luma(env.dir_light.bg_diffuse) > 0.01f) {
out.sun->set_color(rgba(env.dir_light.bg_diffuse));
out.sun->set_param(Light3D::PARAM_ENERGY,
std::clamp(0.9f + luma(env.dir_light.bg_diffuse) * 0.4f, 0.7f, 1.6f));
}
out.sun->set_shadow(true);
out.sun->set_param(Light3D::PARAM_SHADOW_NORMAL_BIAS, 2.0f);
out.sun->set_param(Light3D::PARAM_SHADOW_BIAS, 0.06f);
out.sun->set_param(Light3D::PARAM_SHADOW_MAX_DISTANCE, 500.0f);
out.sun->set_param(Light3D::PARAM_SHADOW_SPLIT_1_OFFSET, 0.08f);
out.sun->set_param(Light3D::PARAM_SHADOW_SPLIT_2_OFFSET, 0.22f);
out.sun->set_param(Light3D::PARAM_SHADOW_SPLIT_3_OFFSET, 0.5f);
out.sun->set_shadow_mode(DirectionalLight3D::SHADOW_PARALLEL_4_SPLITS);
// 角色/物体的间接补光:暖色,能量取自 Material.Ambient
out.sun->set_param(Light3D::PARAM_SPECULAR, 0.4f);
// --- WorldEnvironment ---
out.world_env =
Object::cast_to<WorldEnvironment>(parent->get_node_or_null(NodePath("WorldEnv")));
if (!out.world_env) {
out.world_env = memnew(WorldEnvironment);
out.world_env->set_name("WorldEnv");
parent->add_child(out.world_env);
}
Ref<godot::Environment> e = out.world_env->get_environment();
if (e.is_null())
e.instantiate();
// 天空:SkyBox Gradient -> ProceduralSky 的三段色(zenith / horizon / ground
{
Ref<ProceduralSkyMaterial> psm;
psm.instantiate();
const auto &g = env.sky.gradient;
if (g.size() >= 2) {
psm->set_sky_top_color(rgba(g.front()));
psm->set_sky_horizon_color(rgba(g[g.size() / 2]));
psm->set_ground_horizon_color(rgba(g.back()));
Color gb = rgba(g.back());
psm->set_ground_bottom_color(Color(gb.r * 0.6f, gb.g * 0.6f, gb.b * 0.65f));
}
psm->set_sun_angle_max(6.0f);
Ref<Sky> sky;
sky.instantiate();
sky->set_material(psm);
e->set_sky(sky);
e->set_background(godot::Environment::BG_SKY);
}
// 环境光:Material.Ambient 定色 + 亮度(Metin2 用暖色环境光提亮阴影面)
e->set_ambient_source(godot::Environment::AMBIENT_SOURCE_COLOR);
Color amb = rgba(env.material.ambient);
e->set_ambient_light_color(amb);
e->set_ambient_light_energy(std::clamp(0.5f + luma(env.material.ambient) * 0.4f, 0.35f, 0.95f));
e->set_ambient_light_sky_contribution(0.35f);
// Emissive 当作全局轻微自发光提亮(避免死黑)
e->set_bg_energy_multiplier(1.0f);
// 雾:优先用 .msenv 的 NearDistance/FarDistancecmA1 = 5000/20000 -> 50/200m
// 太近,客户端 D3DFOG 实际按更大的世界尺度;乘一个系数放到远景轻霭区)。没给
// 距离就退回 foglevel 启发式。参考端是线性远景雾 + 天空同色,不是浓雾。
if (env.fog.enable) {
e->set_fog_enabled(true);
e->set_fog_light_color(rgba(env.fog.color));
e->set_fog_mode(godot::Environment::FOG_MODE_DEPTH);
float begin_m, end_m;
if (env.fog.near_distance > 1.0f && env.fog.far_distance > env.fog.near_distance) {
begin_m = env.fog.near_distance * (float)fmt::m2coord::CM_TO_M;
end_m = env.fog.far_distance * (float)fmt::m2coord::CM_TO_M;
// 客户端摄距比我们远:把近雾往后推一截,别糊住中景
begin_m = std::max(begin_m, 120.0f);
end_m = std::max(end_m, begin_m + 400.0f);
} else {
float fl = env.fog.fog_level > 0 ? float(env.fog.fog_level) : 4.0f;
begin_m = std::clamp((11.0f - fl) * 45.0f, 60.0f, 500.0f);
end_m = begin_m + 700.0f;
}
e->set_fog_depth_begin(begin_m);
e->set_fog_depth_end(end_m);
e->set_fog_depth_curve(0.5f);
e->set_fog_density(0.0f);
e->set_fog_sky_affect(0.0f); // 天空自己是渐变,不要被雾再洗一层
e->set_fog_sun_scatter(0.05f);
} else {
e->set_fog_enabled(false);
}
// 色调:参考端是 DX9 定功能、LDR、无 tonemap/HDR。用 LINEAR + 曝光 1 最贴近,
// Filmic 会抬黑、降饱和 -> 画面发灰。轻微加饱和/对比补回胶片感。
e->set_tonemapper(godot::Environment::TONE_MAPPER_LINEAR);
e->set_tonemap_exposure(1.0f);
e->set_adjustment_enabled(true);
e->set_adjustment_saturation(1.12f);
e->set_adjustment_contrast(1.05f);
e->set_adjustment_brightness(1.0f);
// glow:参考端没有 bloom。留极轻的,只有真过曝才溢。
e->set_glow_enabled(true);
e->set_glow_intensity(0.06f);
e->set_glow_strength(0.7f);
e->set_glow_bloom(0.0f);
e->set_glow_hdr_bleed_threshold(1.6f);
// SSAO:参考端把接触阴影烘进地形阴影贴图。这里用一点实时 SSAO 代替。
e->set_ssao_enabled(true);
e->set_ssao_radius(1.2f);
e->set_ssao_intensity(0.9f);
out.world_env->set_environment(e);
return out;
}
} // namespace mtgodot
+21
View File
@@ -0,0 +1,21 @@
#pragma once
#include <godot_cpp/classes/directional_light3d.hpp>
#include <godot_cpp/classes/world_environment.hpp>
#include <environment.h>
// W5 —— .msenv(已由 formats/environment 解析)-> Godot 光照 / 天空 / 雾 / 色调。
// SHINSOO §9-W5。DirectionalLight.Background 驱动场景主光;Character 光留待角色材质
// uniform(§9-W5 note)。云 / lens flare 留 R2。
namespace mtgodot {
struct EnvNodes {
godot::DirectionalLight3D *sun = nullptr;
godot::WorldEnvironment *world_env = nullptr;
};
// 在 parent 下建 / 配 DirectionalLight3D + WorldEnvironment。已存在则复用。
EnvNodes apply_environment(const fmt::Environment &env, godot::Node *parent);
} // namespace mtgodot
+77 -15
View File
@@ -18,6 +18,19 @@ using namespace godot;
namespace mtgodot {
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).
@@ -71,18 +84,59 @@ Ref<Skin> build_skin(const gr2::Skeleton &sk) {
return skin;
}
Ref<ArrayMesh> build_mesh(const gr2::FileInfo &fi, bool flip_winding, AABB &out_bounds) {
std::vector<RenderPart> build_parts(const gr2::FileInfo &fi) {
std::vector<RenderPart> parts;
for (int mi = 0; mi < (int)fi.meshes.size(); ++mi) {
const gr2::Mesh &m = fi.meshes[mi];
if (m.vertices.empty() || m.indices.empty()) {
continue;
}
const uint32_t total = (uint32_t)m.indices.size();
if (m.tri_groups.size() <= 1) {
RenderPart p;
p.mesh = mi;
p.mat_index = m.tri_groups.empty() ? -1 : m.tri_groups[0].material_index;
p.idx_first = 0;
p.idx_count = total;
parts.push_back(p);
continue;
}
for (int g = 0; g < (int)m.tri_groups.size(); ++g) {
const gr2::TriGroup &tg = m.tri_groups[g];
if (tg.tri_count <= 0) {
continue;
}
uint32_t first = (uint32_t)(tg.tri_first < 0 ? 0 : tg.tri_first) * 3u;
uint32_t count = (uint32_t)tg.tri_count * 3u;
if (first >= total) {
continue;
}
if (first + count > total) {
count = total - first;
}
RenderPart p;
p.mesh = mi;
p.group = g;
p.mat_index = tg.material_index;
p.idx_first = first;
p.idx_count = count;
parts.push_back(p);
}
}
return parts;
}
Ref<ArrayMesh> build_mesh(const gr2::FileInfo &fi, const std::vector<RenderPart> &parts,
bool flip_winding, AABB &out_bounds) {
Ref<ArrayMesh> am;
am.instantiate();
am->set_name("ArrayMesh");
bool have_bounds = false;
for (size_t mi = 0; mi < fi.meshes.size(); ++mi) {
const gr2::Mesh &m = fi.meshes[mi];
if (m.vertices.empty() || m.indices.empty()) {
continue;
}
// 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;
@@ -144,18 +198,23 @@ Ref<ArrayMesh> build_mesh(const gr2::FileInfo &fi, bool flip_winding, AABB &out_
}
}
// indices: this part's sub-range of mesh.indices only
const uint32_t ib = part.idx_first;
const uint32_t ic = (part.idx_count && part.idx_first + part.idx_count <= m.indices.size())
? part.idx_count
: (uint32_t)m.indices.size() - ib;
PackedInt32Array idx;
idx.resize((int)m.indices.size());
idx.resize((int)ic);
int32_t *idx_w = idx.ptrw();
if (flip_winding) {
for (size_t t = 0; t + 2 < m.indices.size(); t += 3) {
idx_w[t + 0] = (int)m.indices[t + 0];
idx_w[t + 1] = (int)m.indices[t + 2];
idx_w[t + 2] = (int)m.indices[t + 1];
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 (size_t t = 0; t < m.indices.size(); ++t) {
idx_w[t] = (int)m.indices[t];
for (uint32_t t = 0; t < ic; ++t) {
idx_w[t] = (int)m.indices[ib + t];
}
}
@@ -169,8 +228,11 @@ Ref<ArrayMesh> build_mesh(const gr2::FileInfo &fi, bool flip_winding, AABB &out_
arrays[Mesh::ARRAY_INDEX] = idx;
am->add_surface_from_arrays(Mesh::PRIMITIVE_TRIANGLES, arrays);
am->surface_set_name(am->get_surface_count() - 1,
m.name.empty() ? String("surf_") + itos((int)mi) : String(m.name.c_str()));
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) {
+21 -2
View File
@@ -14,6 +14,9 @@
#include <gr2/gr2.h>
#include <cstdint>
#include <vector>
namespace godot {
class Skeleton3D;
class Skin;
@@ -25,6 +28,10 @@ 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);
@@ -32,15 +39,27 @@ godot::Transform3D make_conv(float unit_scale, bool flip_z);
// folded into root bones). Returns nullptr if the skeleton has no bones.
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 gr2 Mesh that has geometry.
// One ArrayMesh with a surface per RenderPart (see build_parts).
// ARRAY_BONES values are skeleton bone indices (mesh slot -> bone via
// 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, bool flip_winding,
godot::Ref<godot::ArrayMesh> build_mesh(const gr2::FileInfo &fi,
const std::vector<RenderPart> &parts, bool flip_winding,
godot::AABB &out_bounds);
} // namespace mtgodot
+169 -7
View File
@@ -1,5 +1,6 @@
#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>
@@ -19,15 +20,100 @@ 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;
ALPHA = ((mode == 1) ? c.a : 1.0) * lod_fade;
// Opaque only: client early-outs to plain diffuse when D3DRS_ALPHABLENDENABLE.
// stage1 COLOROP MODULATEALPHA_ADDCOLOR = CURRENT.rgb + CURRENT.a*sphere.rgb,
// CURRENT.a = tex.a * D3DRS_TEXTUREFACTOR.a (= spec_power). texcoord =
// D3DTSS_TCI_CAMERASPACEREFLECTIONVECTOR -> view-space reflect(); .xy as UV.
if (spec_enable && spec_power > 0.0 && mode == 0) {
// Sphere-map metallic sheen (EterGrnLib/Material.cpp:305). The client masks
// this with the armor texture's alpha (metal=1, cloth=0); our loose-file
// texture resolution can't be trusted for that alpha, so bias it to
// grazing angles (Fresnel) and knock the level down — reads as an edge
// sheen instead of a full-body chrome mirror.
vec3 vdir = normalize(VERTEX);
vec3 ndir = normalize(NORMAL);
float fres = pow(clamp(1.0 - abs(dot(ndir, vdir)), 0.0, 1.0), 3.0);
vec3 refl = reflect(vdir, ndir);
EMISSION = texture(spec_map, refl.xy * 0.5 + 0.5).rgb
* (spec_mask * spec_power * fres * 0.5);
}
}
)";
// Skinned variant of SRC_MIX: LBS in vertex() with FULL per-bone matrices from a
// float texture (no Skeleton3D). bones_tex is RGBAF, 3 x bone_count; row = bone,
// texel j = column j of the row-vector skin matrix (skinned = [pos 1] * M).
const char *SRC_SKIN = R"(shader_type spatial;
render_mode blend_mix, depth_draw_opaque, cull_back, diffuse_lambert, specular_disabled;
uniform sampler2D albedo_tex : source_color, filter_linear_mipmap, repeat_enable;
uniform bool use_texture = true;
uniform vec4 modulate : source_color = vec4(1.0);
uniform float alpha_scissor : hint_range(0.0, 1.0) = 0.5;
uniform int mode = 0;
uniform sampler2D bones_tex : filter_nearest; // RGBAF, 3 x bone_count
uniform sampler2D spec_map : source_color, filter_linear_mipmap, repeat_enable;
uniform float spec_power = 0.0;
uniform bool spec_enable = false;
uniform float lod_fade = 1.0; // LOD crossfade multiplier (1 = fully shown)
void vertex() {
vec4 p = vec4(VERTEX, 1.0);
vec3 sp = vec3(0.0);
vec3 sn = vec3(0.0);
ivec4 bi = ivec4(BONE_INDICES);
vec4 bw = BONE_WEIGHTS;
float wsum = bw.x + bw.y + bw.z + bw.w;
if (wsum <= 0.0) { bw = vec4(1.0, 0.0, 0.0, 0.0); wsum = 1.0; }
for (int k = 0; k < 4; k++) {
float w = bw[k] / wsum;
if (w <= 0.0) { continue; }
int r = bi[k];
vec4 c0 = texelFetch(bones_tex, ivec2(0, r), 0);
vec4 c1 = texelFetch(bones_tex, ivec2(1, r), 0);
vec4 c2 = texelFetch(bones_tex, ivec2(2, r), 0);
sp += w * vec3(dot(p, c0), dot(p, c1), dot(p, c2));
sn += w * vec3(dot(NORMAL, c0.xyz), dot(NORMAL, c1.xyz), dot(NORMAL, c2.xyz));
}
VERTEX = sp;
NORMAL = normalize(sn);
}
void fragment() {
vec4 c = use_texture ? texture(albedo_tex, UV) : vec4(1.0);
float spec_mask = c.a;
c *= modulate;
if (mode == 2 && c.a < alpha_scissor) { discard; }
ALBEDO = c.rgb;
ALPHA = ((mode == 1) ? c.a : 1.0) * lod_fade;
if (spec_enable && spec_power > 0.0 && mode == 0) {
// Sphere-map metallic sheen (EterGrnLib/Material.cpp:305). The client masks
// this with the armor texture's alpha (metal=1, cloth=0); our loose-file
// texture resolution can't be trusted for that alpha, so bias it to
// grazing angles (Fresnel) and knock the level down — reads as an edge
// sheen instead of a full-body chrome mirror.
vec3 vdir = normalize(VERTEX);
vec3 ndir = normalize(NORMAL);
float fres = pow(clamp(1.0 - abs(dot(ndir, vdir)), 0.0, 1.0), 3.0);
vec3 refl = reflect(vdir, ndir);
EMISSION = texture(spec_map, refl.xy * 0.5 + 0.5).rgb
* (spec_mask * spec_power * fres * 0.5);
}
}
)";
@@ -47,13 +133,67 @@ void fragment() {
}
)";
Ref<Shader> shader_for(bool additive) {
static Ref<Shader> s_mix;
static Ref<Shader> s_add;
Ref<Shader> &slot = additive ? s_add : s_mix;
// 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> shader_for(bool additive, bool skinned, bool two_sided) {
Ref<Shader> &slot = two_sided
? (additive ? (skinned ? s_add_skin_2s : s_add_2s) : (skinned ? s_skin_2s : s_mix_2s))
: (additive ? (skinned ? s_add_skin : s_add) : (skinned ? s_skin : s_mix));
if (slot.is_null()) {
slot.instantiate();
slot->set_code(additive ? SRC_ADD : SRC_MIX);
const char *base =
additive ? (skinned ? SRC_ADD_SKIN : SRC_ADD) : (skinned ? SRC_SKIN : SRC_MIX);
slot->set_code(two_sided ? String(base).replace("cull_back", "cull_disabled")
: String(base));
}
return slot;
}
@@ -65,7 +205,11 @@ Ref<ShaderMaterial> make_material(const MaterialDesc &d) {
m.instantiate();
const bool additive = (d.blend == BlendMode::Add);
m->set_shader(shader_for(additive));
const bool skinned = d.skinned;
m->set_shader(shader_for(additive, skinned, d.two_sided));
if (skinned && d.bones_tex.is_valid()) {
m->set_shader_parameter("bones_tex", d.bones_tex);
}
const bool has_tex = d.albedo.is_valid();
m->set_shader_parameter("use_texture", has_tex);
@@ -75,6 +219,13 @@ Ref<ShaderMaterial> make_material(const MaterialDesc &d) {
m->set_shader_parameter("modulate", has_tex ? Color(1, 1, 1, 1) : Color(0.8, 0.8, 0.82, 1));
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) {
@@ -92,4 +243,15 @@ Ref<ShaderMaterial> make_material(const MaterialDesc &d) {
return m;
}
void cleanup_material_shaders() {
s_mix.unref();
s_add.unref();
s_skin.unref();
s_add_skin.unref();
s_mix_2s.unref();
s_add_2s.unref();
s_skin_2s.unref();
s_add_skin_2s.unref();
}
} // namespace mtgodot
+24
View File
@@ -12,6 +12,7 @@
namespace godot {
class ShaderMaterial;
class Texture2D;
class ImageTexture;
} // namespace godot
namespace mtgodot {
@@ -28,8 +29,31 @@ struct MaterialDesc {
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
+266 -49
View File
@@ -1,5 +1,6 @@
#include "metin2_anim.h"
#include "asset_io.h"
#include "gr2_bridge.h"
#include "metin2_model.h"
@@ -13,14 +14,9 @@
#include <godot_cpp/classes/mesh_instance3d.hpp>
#include <godot_cpp/classes/os.hpp>
#include <godot_cpp/classes/skin.hpp>
static double _tdiff(const godot::Transform3D &a, const godot::Transform3D &b) {
double d = 0.0;
for (int r = 0; r < 3; ++r)
for (int c = 0; c < 3; ++c)
d = godot::Math::max(d, (double)godot::Math::abs(a.basis[r][c] - b.basis[r][c]));
return godot::Math::max(d, (double)(a.origin - b.origin).length());
}
#include <godot_cpp/classes/file_access.hpp>
#include <godot_cpp/variant/array.hpp>
#include <godot_cpp/variant/dictionary.hpp>
using namespace godot;
@@ -36,25 +32,65 @@ void Metin2AnimPlayer::_bind_methods() {
ClassDB::bind_method(D_METHOD("get_model_path"), &Metin2AnimPlayer::get_model_path);
ClassDB::bind_method(D_METHOD("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_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_loop_data"), &Metin2AnimPlayer::get_loop_data);
ADD_PROPERTY(PropertyInfo(Variant::STRING, "anim_path", PROPERTY_HINT_GLOBAL_FILE, "*.gr2"),
// Fired when playback time crosses a .msa MotionEventData entry.
ADD_SIGNAL(MethodInfo("motion_event",
PropertyInfo(Variant::INT, "type"),
PropertyInfo(Variant::STRING, "effect"),
PropertyInfo(Variant::STRING, "sound"),
PropertyInfo(Variant::VECTOR3, "pos")));
ADD_SIGNAL(MethodInfo("playback_finished"));
ADD_PROPERTY(PropertyInfo(Variant::STRING, "anim_path", PROPERTY_HINT_GLOBAL_FILE, "*.gr2,*.msa"),
"set_anim_path", "get_anim_path");
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::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) {
return;
}
// Start a crossfade from the clip that is currently playing.
if (is_inside_tree() && anim_file && blend_time > 0.0) {
prev_anim_file = std::move(anim_file); // anim_file now empty; reload() refills it
prev_anim_duration = duration;
prev_anim_loop = loop;
double pt = time;
if (prev_anim_duration > 0.0) {
if (prev_anim_loop) {
pt = std::fmod(pt, prev_anim_duration);
if (pt < 0.0) {
pt += prev_anim_duration;
}
} else {
pt = std::fmax(0.0, std::fmin(pt, prev_anim_duration));
}
}
prev_anim_time = pt;
blend_elapsed = 0.0;
}
anim_path = p;
if (is_inside_tree()) {
reload();
@@ -64,7 +100,8 @@ void Metin2AnimPlayer::set_model_path(const NodePath &p) {
model_path = p;
}
void Metin2AnimPlayer::set_time(double t) {
time = t;
time = (!loop && duration > 0.0) ? std::fmax(0.0, std::fmin(t, duration)) : t;
prev_time = time; // scrubbing must not replay every event since the old cursor
if (is_inside_tree()) {
apply_pose(time);
}
@@ -78,6 +115,78 @@ Metin2Model *Metin2AnimPlayer::resolve_model() const {
return Object::cast_to<Metin2Model>(n);
}
// "d:\Ymir Work\pc\warrior\action\dance.gr2" -> "<root>/PC/ymir work/pc/warrior/action/dance.gr2"
// where <root> is found by walking up from the .msa's own dir past "ymir work".
// Falls back to <msa_dir>/<basename> (the anim gr2 is usually right next to it).
static String strip_dpath(const String &raw) {
String p = raw.replace("\\", "/");
int k = p.to_lower().find("ymir work");
return k >= 0 ? p.substr(k) : p; // "ymir work/pc/.../x.gr2"
}
String Metin2AnimPlayer::resolve_anim_gr2(const String &spec) {
String low = spec.to_lower();
if (!low.ends_with(".msa")) {
return spec; // already a .gr2 path
}
fmt::Msa m;
std::string e;
if (!fmt::parse_msa_file(std::string(spec.utf8().get_data()), m, &e)) {
UtilityFunctions::push_error(String("[Metin2AnimPlayer] .msa parse: ") + String(e.c_str()));
return spec;
}
const String motion = String(m.motion_gr2.c_str());
const String base = motion.replace("\\", "/").get_file();
const String msa_dir = spec.get_base_dir();
// 1. sibling of the .msa
String cand = msa_dir.path_join(base);
if (FileAccess::file_exists(cand)) {
return cand;
}
// 2. <root>/PC/<ymir-work tail>, root = ancestor of the .msa above "ymir work"
String tail = strip_dpath(motion); // "ymir work/pc/.../x.gr2"
int ky = msa_dir.to_lower().find("ymir work");
if (ky > 0) {
String root = spec.substr(0, ky); // ".../assets/PC/"
cand = root.path_join(tail.substr(String("ymir work/").length()));
if (FileAccess::file_exists(cand)) {
return cand;
}
cand = root.get_base_dir().path_join(tail); // ".../assets/" + "ymir work/..."
if (FileAccess::file_exists(cand)) {
return cand;
}
}
UtilityFunctions::push_warning(String("[Metin2AnimPlayer] .msa motion gr2 not found: ") + motion +
" (tried " + msa_dir.path_join(base) + ")");
return cand;
}
Array Metin2AnimPlayer::get_events() const {
Array a;
for (const fmt::MotionEvent &ev : events) {
Dictionary d;
d["type"] = ev.type;
d["start_time"] = ev.start_time;
d["effect"] = String(ev.effect_file.c_str());
d["sound"] = String(ev.sound_file.c_str());
d["pos"] = Vector3(ev.position[0], ev.position[1], ev.position[2]);
a.push_back(d);
}
return a;
}
Dictionary Metin2AnimPlayer::get_loop_data() const {
Dictionary d;
d["present"] = msa_metadata.has_loop_data;
d["count"] = msa_metadata.motion_loop_count;
d["cancel_enable"] = msa_metadata.loop_cancel_enable;
d["start_time"] = msa_metadata.loop_start_time;
d["end_time"] = msa_metadata.loop_end_time;
return d;
}
void Metin2AnimPlayer::_ready() {
set_process(true);
reload();
@@ -87,13 +196,38 @@ void Metin2AnimPlayer::reload() {
anim_file = std::nullopt;
duration = 0.0;
time = 0.0;
prev_time = 0.0;
next_event = 0;
accumulation = Vector3();
events.clear();
msa_metadata = fmt::Msa{};
last_info = "";
if (anim_path.is_empty()) {
return;
}
// .msa -> resolve the real motion .gr2 + pull accumulation / events.
String gr2_spec = anim_path;
if (anim_path.to_lower().ends_with(".msa")) {
fmt::Msa m;
std::string e;
if (fmt::parse_msa_file(std::string(anim_path.utf8().get_data()), m, &e)) {
msa_metadata = m;
accumulation = Vector3(m.accumulation[0], m.accumulation[1], m.accumulation[2]);
events = m.events;
gr2_spec = resolve_anim_gr2(anim_path);
UtilityFunctions::print(vformat("[Metin2AnimPlayer] .msa -> %s accum=(%.2f %.2f %.2f) events=%d loopdata=%s",
gr2_spec, accumulation.x, accumulation.y, accumulation.z, (int)events.size(),
m.has_loop_data ? vformat("%d x [%.3f,%.3f]", m.motion_loop_count,
m.loop_start_time, m.loop_end_time) : String("none")));
} else {
UtilityFunctions::push_error(String("[Metin2AnimPlayer] .msa: ") + String(e.c_str()));
return;
}
}
gr2::LoadError err;
const std::string p(anim_path.utf8().get_data());
auto f = gr2::File::load_path(p, &err);
auto f = mtgodot::gr2_from_file(gr2_spec, &err);
if (!f) {
UtilityFunctions::push_error(String("[Metin2AnimPlayer] anim load failed [") +
String(err.stage.c_str()) + "]: " + String(err.message.c_str()));
@@ -139,6 +273,36 @@ void Metin2AnimPlayer::reload() {
apply_pose(0.0);
}
namespace {
// mul4x3 lives in gr2_bridge.h now (shared with the weapon-attach grip compose).
// Blend two bone world transforms: slerp rotation, lerp translation & scale
// (PARITY §2.9). Shear on the ~few scaleshear bones is dropped for the ≤blend_time
// transient only — the doc sanctions "quat slerp + pos/scale lerp".
gr2::Mat4 blend_trs(const gr2::Mat4 &a, const gr2::Mat4 &b, float w) {
godot::Basis ba(godot::Vector3(a[0], a[1], a[2]), godot::Vector3(a[4], a[5], a[6]),
godot::Vector3(a[8], a[9], a[10]));
godot::Basis bb(godot::Vector3(b[0], b[1], b[2]), godot::Vector3(b[4], b[5], b[6]),
godot::Vector3(b[8], b[9], b[10]));
godot::Quaternion q = ba.get_rotation_quaternion().slerp(bb.get_rotation_quaternion(), w);
godot::Vector3 s = ba.get_scale().lerp(bb.get_scale(), w);
godot::Vector3 tt =
godot::Vector3(a[12], a[13], a[14]).lerp(godot::Vector3(b[12], b[13], b[14]), w);
godot::Basis rb(q);
rb.rows[0] *= s.x;
rb.rows[1] *= s.y;
rb.rows[2] *= s.z;
gr2::Mat4 o{};
o[0] = rb.rows[0].x; o[1] = rb.rows[0].y; o[2] = rb.rows[0].z;
o[4] = rb.rows[1].x; o[5] = rb.rows[1].y; o[6] = rb.rows[1].z;
o[8] = rb.rows[2].x; o[9] = rb.rows[2].y; o[10] = rb.rows[2].z;
o[12] = tt.x; o[13] = tt.y; o[14] = tt.z; o[15] = 1.0f;
return o;
}
} // namespace
void Metin2AnimPlayer::apply_pose(double t) {
if (!anim_file) {
return;
@@ -155,61 +319,114 @@ void Metin2AnimPlayer::apply_pose(double t) {
const gr2::Animation &an = anim_file->file_info().animations[0];
double tt = t;
if (duration > 0.0) {
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));
}
gr2::sample_pose(*sk, an, (float)tt, world_buf, skin_buf);
if (godot::OS::get_singleton()->get_environment("MTGODOT_CPUSKIN") == "1") {
// Crossfade: blend the frozen outgoing-clip pose into this one, then rebuild
// skin_buf = invWorld · world_blended (PARITY §2.9).
if (prev_anim_file && blend_time > 0.0 && blend_elapsed < blend_time) {
const gr2::FileInfo &pfi = prev_anim_file->file_info();
if (!pfi.animations.empty()) {
gr2::sample_pose(*sk, pfi.animations[0], (float)prev_anim_time, prev_world_buf,
prev_skin_buf);
double w = blend_elapsed / blend_time;
// ease-in on the incoming clip, matching the client's
// GrannySetControlEaseInCurve(t0,t1, 0,0,1,1) Hermite (p0=0,m0=0,
// p1=1,m1=1) -> h(w) = 2w^2 - w^3. Flat start, slope-1 finish.
w = w * w * (2.0 - w);
const size_t n = std::min(world_buf.size(), prev_world_buf.size());
blend_world_buf.resize(world_buf.size());
for (size_t i = 0; i < n; ++i)
blend_world_buf[i] = blend_trs(prev_world_buf[i], world_buf[i], (float)w);
for (size_t i = n; i < world_buf.size(); ++i)
blend_world_buf[i] = world_buf[i];
const auto &bones = sk->bones;
for (size_t i = 0; i < world_buf.size() && i < bones.size(); ++i)
skin_buf[i] = mul4x3(bones[i].inverse_world, blend_world_buf[i]);
world_buf.swap(blend_world_buf); // weapon attach follows the blended hand
}
}
// Both skinning paths apply libgr2's per-bone deformer matrices
// (skin_buf = Σ w · invWorld · world) with the FULL affine — shear kept.
// default : CPU LBS, ArrayMesh rebuilt each frame.
// MTGODOT_GPUSKIN=1 : LBS in a custom vertex shader (bone matrices in a
// float texture, no Skeleton3D). See m2_material SRC_SKIN.
// The old Skeleton3D::set_bone_pose route is gone — Godot's built-in skinning
// orthonormalizes the bone matrix and drops the shear (docs/MIDREVIEW.md §4).
if (godot::OS::get_singleton()->get_environment("MTGODOT_GPUSKIN") == "1") {
model->enable_gpu_skin(true);
model->gpu_skin(skin_buf);
} else {
model->enable_cpu_skin(true);
model->cpu_skin(skin_buf);
return;
}
// Convert gr2 global poses -> Godot *local* bone poses and set those.
// (Setting global poses directly hits a parent-ordering hazard in
// Skeleton3D::set_bone_global_pose that detaches head/upper-body meshes
// once bones leave the bind pose.)
const int n = (int)std::min<size_t>(world_buf.size(),
std::min<size_t>(sk->bones.size(), (size_t)skel->get_bone_count()));
for (int i = 0; i < n; ++i) {
const godot::Transform3D t_i = gr2_to_godot(world_buf[i]);
const int parent = sk->bones[i].parent;
if (parent < 0 || parent >= n) {
skel->set_bone_pose(i, t_i);
} else {
skel->set_bone_pose(i, gr2_to_godot(world_buf[parent]).affine_inverse() * t_i);
}
}
if (!verified_ && OS::get_singleton()->get_environment("MTGODOT_VERIFY") == "1") {
verified_ = true;
skel->force_update_all_bone_transforms();
double d_pose = 0.0;
int w_pose = -1;
for (int i = 0; i < n; ++i) {
double dp = _tdiff(skel->get_bone_global_pose(i), gr2_to_godot(world_buf[i]));
if (dp > d_pose) {
d_pose = dp;
w_pose = i;
}
}
UtilityFunctions::print(vformat(
"[Metin2AnimPlayer] verify: max |global_pose - conv(world)| = %.6f @ bone %d '%s'",
d_pose, w_pose, w_pose >= 0 ? String(sk->bones[w_pose].name.c_str()) : String()));
}
// Rigid weapon follows equip_right_hand's animated world transform (PARITY §2.1).
model->update_weapon_pose(world_buf);
}
void Metin2AnimPlayer::_process(double delta) {
if (!playing || !anim_file) {
return;
}
time += delta * time_scale;
if (prev_anim_file) {
blend_elapsed += delta; // real seconds, independent of time_scale
if (blend_elapsed >= blend_time) {
prev_anim_file = std::nullopt;
}
}
double next = time + delta * time_scale;
if (!loop && duration > 0.0 && next >= duration) {
next = duration;
}
time = next;
apply_pose(time);
dispatch_events(prev_time, time);
prev_time = time;
if (!loop && duration > 0.0 && time >= duration) {
playing = false;
emit_signal("playback_finished");
}
}
// Emit `motion_event` for every .msa event whose start_time falls in (from, to],
// handling loop wrap-around within one clip.
void Metin2AnimPlayer::dispatch_events(double from, double to) {
if (events.empty() || duration <= 0.0) {
return;
}
if (!loop) {
double a = std::fmax(0.0, std::fmin(from, duration));
double b = std::fmax(0.0, std::fmin(to, duration));
for (const fmt::MotionEvent &ev : events) {
if (a < ev.start_time && ev.start_time <= b) {
emit_signal("motion_event", ev.type, String(ev.effect_file.c_str()),
String(ev.sound_file.c_str()),
Vector3(ev.position[0], ev.position[1], ev.position[2]));
}
}
return;
}
double a = std::fmod(from, duration);
if (a < 0) a += duration;
double b = a + (to - from);
for (const fmt::MotionEvent &ev : events) {
double t = ev.start_time;
bool hit = (a < t && t <= b) || (b > duration && t <= b - duration);
if (hit) {
emit_signal("motion_event", ev.type, String(ev.effect_file.c_str()),
String(ev.sound_file.c_str()),
Vector3(ev.position[0], ev.position[1], ev.position[2]));
}
}
}
String Metin2AnimPlayer::selfcheck(int samples) {
+45 -7
View File
@@ -1,10 +1,13 @@
#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 <vector>
@@ -13,14 +16,18 @@ namespace mtgodot {
class Metin2Model;
// Drives a Metin2Model's Skeleton3D each frame by sampling a (possibly
// separate) animation .gr2 with libgr2, then writing per-bone global poses.
// Drives a Metin2Model each frame by sampling a (possibly separate) animation
// .gr2 with libgr2, then skinning it with the resulting matrices:
//
// world[i] = gr2::sample_pose(model.skeleton, anim, t)
// skel.set_bone_global_pose(i, gr2_to_godot(world[i]))
// sample_pose(model.skeleton, anim, t) -> world[i], skin[i]
// skin[i] = inverse_world[i] * world[i] (gr2 deformer matrix per bone)
//
// Godot's renderer then computes skin matrix = global_pose(i) * bind_pose(i)
// which equals gr2's deformer matrix (see gr2_bridge.h).
// 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)
@@ -39,10 +46,17 @@ public:
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; }
// 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; }
@@ -50,6 +64,11 @@ public:
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; // [{type,start_time,effect,sound,pos}, ...]
godot::Dictionary get_loop_data() const;
// NaN-scan every animation in anim_path across [0,dur]; returns a report string.
godot::String selfcheck(int samples = 24);
@@ -60,18 +79,37 @@ private:
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;
double time = 0.0;
double duration = 0.0;
bool verified_ = false;
godot::String last_info;
std::optional<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::optional<gr2::File> prev_anim_file;
double prev_anim_time = 0.0;
double prev_anim_duration = 0.0;
bool prev_anim_loop = true;
double blend_elapsed = 0.0;
std::vector<gr2::Mat4> prev_world_buf, prev_skin_buf, blend_world_buf;
godot::Vector3 accumulation; // .msa Accumulation (root motion)
std::vector<fmt::MotionEvent> events;
fmt::Msa msa_metadata;
int next_event = 0; // index into `events`, for _process dispatch
double prev_time = 0.0;
Metin2Model *resolve_model() const;
void apply_pose(double t);
void dispatch_events(double from, double to);
// .msa path -> real motion .gr2 path; passes plain .gr2 paths through.
static godot::String resolve_anim_gr2(const godot::String &spec);
};
} // namespace mtgodot
File diff suppressed because it is too large Load Diff
+136 -2
View File
@@ -1,15 +1,19 @@
#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/packed_string_array.hpp>
#include <godot_cpp/variant/string.hpp>
#include <gr2/gr2.h>
#include "gr2_bridge.h"
#include <memory>
#include <optional>
#include <vector>
@@ -35,8 +39,17 @@ public:
~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; }
@@ -58,9 +71,56 @@ public:
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);
// 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();
@@ -71,6 +131,9 @@ public:
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/
@@ -79,6 +142,13 @@ public:
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;
@@ -93,8 +163,47 @@ private:
bool flip_winding = false;
godot::String material_mode = "metin2"; // "metin2" (ShaderMaterial) | "standard"
bool use_gr2_materials = false; // drive textures from gr2 material names (unverified)
bool use_gr2_materials = true; // gr2 MaterialBindings -> texture (else: filename heuristic only)
double specular_power = 0.0; // PARITY §2.7; 0 = disabled
godot::Ref<godot::Texture2D> sphere_map; // shared sphere map, lazy-loaded
godot::Ref<godot::Texture2D> _load_sphere_map(); // "ymir work/special/spheremap.jpg"
godot::PackedStringArray surface_tex_override;
godot::String resolved_gr2_dir; // dir of the actually-loaded .gr2 (for .msm)
godot::Array hair_options; // from .msm HairData
godot::String hair_gr2; // attached hair .gr2 (or "")
godot::String hair_skin; // TargetSkin dds override (or "")
std::shared_ptr<std::optional<gr2::File>> hair_file; // loaded hair gr2
std::vector<mtgodot::RenderPart> hair_parts;
std::vector<int> hair_bone_remap; // hair skel idx -> base skel idx (by name)
godot::Ref<godot::Material> hair_mat;
godot::Ref<godot::ImageTexture> hair_tex; // resolved hair albedo (for GPU path)
int gpu_base_surf = 0; // base surfaces before appended GPU hair
void _load_hair();
void _build_gpu_mesh(); // base + remapped hair surfaces -> mi->mesh (GPU-skin path, §2.2)
godot::String weapon_gr2; // attached weapon .gr2 (or "")
godot::String weapon_bone = "equip_right_hand"; // base-skeleton attach bone
std::shared_ptr<std::optional<gr2::File>> weapon_file; // loaded weapon gr2
godot::MeshInstance3D *weapon_mi = nullptr; // child of this node
int weapon_bone_idx = -1; // base skeleton index of weapon_bone
// weapon's own bone[0] grip transform (invWorld · local); folded in so the mesh
// aligns to the hand even when it is authored offset from its bone (client:
// GrannySampleModelAnimationsAccelerated on the weapon skeleton). Identity when
// the weapon has no skeleton or a ~identity bone.
gr2::Mat4 weapon_pre{ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 };
godot::String shield_gr2;
godot::String shield_bone = "Bip01 L Hand"; // PC skeletons have no equip_left_hand
std::shared_ptr<std::optional<gr2::File>> shield_file;
godot::MeshInstance3D *shield_mi = nullptr;
int shield_bone_idx = -1;
gr2::Mat4 shield_pre{ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 };
void _load_weapon();
void _load_shield();
// shared rigid-attach loader used by _load_weapon / _load_shield.
void _load_attach(const godot::String &gr2_rel, const godot::String &bone_name,
std::shared_ptr<std::optional<gr2::File>> &slot_file, godot::MeshInstance3D *&slot_mi,
int &slot_bone_idx, gr2::Mat4 &slot_pre, const char *node_name, const char *label);
std::shared_ptr<std::optional<gr2::File>> file; // shared_ptr so accessor stays valid
std::vector<gr2::MaterialInfo> materials;
@@ -103,12 +212,37 @@ private:
godot::String last_info;
godot::HashMap<godot::String, godot::Ref<godot::ImageTexture>> tex_cache;
// §2.10 LOD
bool lod_enabled = true;
int lod_level = 0; // 0 = full model, 1..3 = _lod_0N
godot::PackedFloat32Array lod_dist;
std::vector<std::shared_ptr<std::optional<gr2::File>>> lod_files; // [0]=_lod_01 ...
std::vector<std::vector<mtgodot::RenderPart>> lod_parts; // parallel to lod_files
// LOD crossfade: a frozen ghost of the outgoing level fades out while the new
// mesh fades in (LODController::BlendRenderWithOneTexture). ~0.18 s.
godot::MeshInstance3D *lod_prev_mi = nullptr;
double lod_fade_t = -1.0; // <0 = not fading
std::vector<godot::Ref<godot::Material>> lod_ghost_mats;
void _end_lod_fade();
std::vector<mtgodot::RenderPart> base_parts;
void _load_lods(const godot::String &loaded_spec);
void _set_lod(int n);
const gr2::FileInfo *active_fi() const; // base or current LOD (mesh/material data)
std::vector<mtgodot::RenderPart> parts; // surface s -> (gr2 mesh, tri_group, material)
std::vector<godot::Ref<godot::Material>> surf_mats; // resolved once; re-assigned each cpu_skin frame
godot::Ref<godot::ArrayMesh> cpu_mesh; // rebuilt each frame in cpu_skin mode
std::vector<int> cpu_surf_mesh; // surface -> gr2 mesh index
bool gpu_skin_active = false;
std::vector<gr2::Mat4> current_skin;
godot::Ref<godot::Image> bones_img; // RGBAF 3 x bone_count
godot::Ref<godot::ImageTexture> bones_tex;
void _clear_children();
void _apply_materials();
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,
+789
View File
@@ -0,0 +1,789 @@
#include "metin2_world.h"
#include <godot_cpp/classes/array_mesh.hpp>
#include <godot_cpp/classes/box_shape3d.hpp>
#include <godot_cpp/classes/collision_shape3d.hpp>
#include <godot_cpp/classes/file_access.hpp>
#include <godot_cpp/classes/geometry_instance3d.hpp>
#include <godot_cpp/classes/height_map_shape3d.hpp>
#include <godot_cpp/classes/image_texture.hpp>
#include <godot_cpp/classes/static_body3d.hpp>
#include <godot_cpp/classes/mesh_instance3d.hpp>
#include <godot_cpp/classes/multi_mesh.hpp>
#include <godot_cpp/classes/multi_mesh_instance3d.hpp>
#include <godot_cpp/classes/performance.hpp>
#include <godot_cpp/classes/standard_material3d.hpp>
#include <godot_cpp/classes/time.hpp>
#include <godot_cpp/core/class_db.hpp>
#include <godot_cpp/variant/basis.hpp>
#include <godot_cpp/variant/packed_int32_array.hpp>
#include <godot_cpp/variant/packed_vector2_array.hpp>
#include <godot_cpp/variant/packed_vector3_array.hpp>
#include <godot_cpp/variant/transform3d.hpp>
#include <godot_cpp/variant/utility_functions.hpp>
#include "asset_io.h"
#include "gr2_bridge.h"
#include <area_data.h>
#include <environment.h>
#include <m2_coord.h>
#include <property.h>
#include <splat.h>
#include <terrain_mesh.h>
#include "dxt.h"
#include "environment_builder.h"
#include "static_object.h"
#include "water_builder.h"
#include "terrain_splat.h"
#include "tree_placeholder.h"
#include <map>
#include <vector>
#include <algorithm>
#include <cctype>
#include <cmath>
using namespace godot;
namespace mtgodot {
Metin2World::Metin2World() {}
Metin2World::~Metin2World() {}
void Metin2World::_bind_methods() {
ClassDB::bind_method(D_METHOD("set_assets_root", "p"), &Metin2World::set_assets_root);
ClassDB::bind_method(D_METHOD("get_assets_root"), &Metin2World::get_assets_root);
ClassDB::bind_method(D_METHOD("set_map_path", "p"), &Metin2World::set_map_path);
ClassDB::bind_method(D_METHOD("get_map_path"), &Metin2World::get_map_path);
ClassDB::bind_method(D_METHOD("set_load_radius_tiles", "r"), &Metin2World::set_load_radius_tiles);
ClassDB::bind_method(D_METHOD("get_load_radius_tiles"), &Metin2World::get_load_radius_tiles);
ClassDB::bind_method(D_METHOD("set_focus_tile", "t"), &Metin2World::set_focus_tile);
ClassDB::bind_method(D_METHOD("get_focus_tile"), &Metin2World::get_focus_tile);
ClassDB::bind_method(D_METHOD("set_auto_load", "v"), &Metin2World::set_auto_load);
ClassDB::bind_method(D_METHOD("get_auto_load"), &Metin2World::get_auto_load);
ClassDB::bind_method(D_METHOD("set_splat_enabled", "v"), &Metin2World::set_splat_enabled);
ClassDB::bind_method(D_METHOD("get_splat_enabled"), &Metin2World::get_splat_enabled);
ClassDB::bind_method(D_METHOD("set_terrain_patches", "n"), &Metin2World::set_terrain_patches);
ClassDB::bind_method(D_METHOD("get_terrain_patches"), &Metin2World::get_terrain_patches);
ClassDB::bind_method(D_METHOD("set_objects_enabled", "v"), &Metin2World::set_objects_enabled);
ClassDB::bind_method(D_METHOD("get_objects_enabled"), &Metin2World::get_objects_enabled);
ClassDB::bind_method(D_METHOD("set_env_enabled", "v"), &Metin2World::set_env_enabled);
ClassDB::bind_method(D_METHOD("get_env_enabled"), &Metin2World::get_env_enabled);
ClassDB::bind_method(D_METHOD("set_water_enabled", "v"), &Metin2World::set_water_enabled);
ClassDB::bind_method(D_METHOD("get_water_enabled"), &Metin2World::get_water_enabled);
ClassDB::bind_method(D_METHOD("set_tree_shadows", "v"), &Metin2World::set_tree_shadows);
ClassDB::bind_method(D_METHOD("get_tree_shadows"), &Metin2World::get_tree_shadows);
ClassDB::bind_method(D_METHOD("set_static_shadows", "v"), &Metin2World::set_static_shadows);
ClassDB::bind_method(D_METHOD("get_static_shadows"), &Metin2World::get_static_shadows);
ClassDB::bind_method(D_METHOD("set_stream_budget", "v"), &Metin2World::set_stream_budget);
ClassDB::bind_method(D_METHOD("get_stream_budget"), &Metin2World::get_stream_budget);
ClassDB::bind_method(D_METHOD("load_map"), &Metin2World::load_map);
ClassDB::bind_method(D_METHOD("unload_map"), &Metin2World::unload_map);
ClassDB::bind_method(D_METHOD("set_focus_position", "gx_m", "gz_m"),
&Metin2World::set_focus_position);
ClassDB::bind_method(D_METHOD("get_perf"), &Metin2World::get_perf);
ClassDB::bind_method(D_METHOD("get_map_base_cm"), &Metin2World::get_map_base_cm);
ClassDB::bind_method(D_METHOD("get_map_size_tiles"), &Metin2World::get_map_size_tiles);
ClassDB::bind_method(D_METHOD("sample_height", "gx_m", "gz_m"), &Metin2World::sample_height);
ClassDB::bind_method(D_METHOD("sample_attribute", "gx_m", "gz_m"), &Metin2World::sample_attribute);
ClassDB::bind_method(D_METHOD("is_blocked", "gx_m", "gz_m"), &Metin2World::is_blocked);
ClassDB::bind_method(D_METHOD("load_dds", "path"), &Metin2World::load_dds);
ClassDB::bind_method(D_METHOD("chunk_dir", "tx", "ty"), &Metin2World::chunk_dir);
ClassDB::bind_method(D_METHOD("get_load_report"), &Metin2World::get_load_report);
ClassDB::bind_method(D_METHOD("bake_asset_index", "out_path"), &Metin2World::bake_asset_index);
ADD_PROPERTY(PropertyInfo(Variant::STRING, "assets_root", PROPERTY_HINT_GLOBAL_DIR),
"set_assets_root", "get_assets_root");
ADD_PROPERTY(PropertyInfo(Variant::STRING, "map_path"), "set_map_path", "get_map_path");
ADD_PROPERTY(PropertyInfo(Variant::INT, "load_radius_tiles"),
"set_load_radius_tiles", "get_load_radius_tiles");
ADD_PROPERTY(PropertyInfo(Variant::VECTOR2I, "focus_tile"),
"set_focus_tile", "get_focus_tile");
ADD_PROPERTY(PropertyInfo(Variant::BOOL, "auto_load"), "set_auto_load", "get_auto_load");
ADD_PROPERTY(PropertyInfo(Variant::BOOL, "splat_enabled"),
"set_splat_enabled", "get_splat_enabled");
ADD_PROPERTY(PropertyInfo(Variant::INT, "terrain_patches"), "set_terrain_patches", "get_terrain_patches");
ADD_PROPERTY(PropertyInfo(Variant::BOOL, "objects_enabled"),
"set_objects_enabled", "get_objects_enabled");
ADD_PROPERTY(PropertyInfo(Variant::BOOL, "env_enabled"),
"set_env_enabled", "get_env_enabled");
ADD_PROPERTY(PropertyInfo(Variant::BOOL, "water_enabled"),
"set_water_enabled", "get_water_enabled");
ADD_PROPERTY(PropertyInfo(Variant::BOOL, "tree_shadows"),
"set_tree_shadows", "get_tree_shadows");
ADD_PROPERTY(PropertyInfo(Variant::BOOL, "static_shadows"),
"set_static_shadows", "get_static_shadows");
ADD_PROPERTY(PropertyInfo(Variant::INT, "stream_budget"),
"set_stream_budget", "get_stream_budget");
}
void Metin2World::set_assets_root(const String &p) { assets_root = p; }
void Metin2World::set_map_path(const String &p) { map_path = p; }
void Metin2World::set_focus_tile(Vector2i t) { focus_tx = t.x; focus_ty = t.y; }
void Metin2World::_ready() {
set_process(true); // streaming 队列逐帧建
if (auto_load && !assets_root.is_empty())
load_map();
}
String Metin2World::map_dir() const {
String r = assets_root;
if (!r.ends_with("/"))
r += "/";
return r + map_path;
}
const Metin2World::Chunk *Metin2World::chunk_at(int tx, int ty) const {
for (auto &c : chunks)
if (c.tx == tx && c.ty == ty)
return &c;
return nullptr;
}
bool Metin2World::build_chunk(int tx, int ty) {
const std::string dir =
std::string(map_dir().utf8().get_data()) + "/" + fmt::m2coord::tile_dir(tx, ty);
auto hm = std::make_shared<fmt::HeightMap>();
std::string err;
if (!fmt::load_height_map(dir + "/height.raw", *hm, &err)) {
last_error = String("tile ") + fmt::m2coord::tile_dir(tx, ty).c_str() + ": " + err.c_str();
++chunks_failed;
return false;
}
auto am = std::make_shared<fmt::AttrMap>();
if (!fmt::load_attr_map(dir + "/attr.atr", *am, &err))
am.reset(); // 非致命:attr 缺失 -> 该区块无阻挡
fmt::TerrainMesh tmesh;
fmt::build_terrain_mesh(*hm, tx, ty, setting.height_scale, tmesh);
Ref<Material> mat;
{
Ref<StandardMaterial3D> grey;
grey.instantiate();
grey->set_albedo(Color(0.5f, 0.5f, 0.5f));
mat = grey;
}
bool splatted = false;
if (splat_ready && resolver) {
fmt::TileMap tile;
std::string e2;
if (fmt::load_tile_map(dir + "/tile.raw", tile, &e2)) {
fmt::SplatSet ss;
fmt::build_splat(tile, texture_set.runtime_count(), ss);
if (!ss.layers.empty()) {
String smpath;
String sm = String(dir.c_str()) + "/shadowmap.dds";
if (FileAccess::file_exists(sm))
smpath = sm;
Ref<ShaderMaterial> tm = build_chunk_terrain_material(
ss, texture_set, *resolver, smpath);
if (tm.is_valid()) {
mat = tm;
splatted = true;
}
}
}
}
if (splatted)
++chunks_splatted;
// 该区块的场景根 —— terrain / water / 对象 / 树都挂它下面,卸载 = free 它
Node3D *croot = memnew(Node3D);
croot->set_name(String("Chunk_") + fmt::m2coord::tile_dir(tx, ty).c_str());
add_child(croot);
// §3.5: 拆成 N×N patch,逐 patch MeshInstance —— Godot 自动逐 patch 视锥剔除,
// 远处 patch 用 visibility_range 整片剔除。terrain_patches=1 = 旧行为(整区块一 mesh)。
const int QN = fmt::TerrainMesh::QUADS_XY; // 128
const int Pn = (terrain_patches >= 1 && QN % terrain_patches == 0) ? terrain_patches : 1;
const int P = QN / Pn; // 每 patch 边上的 quad 数
const int VN = fmt::TerrainMesh::VERTS_XY; // 129
const int pw = P + 1; // 每 patch 边上的顶点数
for (int pj = 0; pj < Pn; ++pj) {
for (int pi = 0; pi < Pn; ++pi) {
const int i0 = pi * P, j0 = pj * P;
PackedVector3Array pv, pn;
PackedVector2Array pu;
pv.resize(pw * pw);
pn.resize(pw * pw);
pu.resize(pw * pw);
for (int lj = 0; lj < pw; ++lj) {
for (int li = 0; li < pw; ++li) {
const int src = (j0 + lj) * VN + (i0 + li);
const int dst = lj * pw + li;
pv[dst] = Vector3(tmesh.positions[src * 3 + 0], tmesh.positions[src * 3 + 1],
tmesh.positions[src * 3 + 2]);
pn[dst] = Vector3(tmesh.normals[src * 3 + 0], tmesh.normals[src * 3 + 1],
tmesh.normals[src * 3 + 2]);
pu[dst] = Vector2(tmesh.uvs[src * 2 + 0], tmesh.uvs[src * 2 + 1]);
}
}
PackedInt32Array pidx;
pidx.resize(P * P * 6);
int k = 0;
for (int lj = 0; lj < P; ++lj) {
for (int li = 0; li < P; ++li) {
const int TL = lj * pw + li, TR = TL + 1, BL = TL + pw, BR = BL + 1;
pidx[k++] = TL; pidx[k++] = BR; pidx[k++] = BL;
pidx[k++] = TL; pidx[k++] = TR; pidx[k++] = BR;
}
}
Array pa;
pa.resize(Mesh::ARRAY_MAX);
pa[Mesh::ARRAY_VERTEX] = pv;
pa[Mesh::ARRAY_NORMAL] = pn;
pa[Mesh::ARRAY_TEX_UV] = pu;
pa[Mesh::ARRAY_INDEX] = pidx;
Ref<ArrayMesh> pmesh;
pmesh.instantiate();
pmesh->add_surface_from_arrays(Mesh::PRIMITIVE_TRIANGLES, pa);
pmesh->surface_set_material(0, mat);
MeshInstance3D *pm = memnew(MeshInstance3D);
pm->set_name(Pn > 1 ? vformat("Terrain_%d_%d", pi, pj) : String("Terrain"));
pm->set_mesh(pmesh);
if (Pn > 1 && terrain_patch_view > 0.0f) {
// 硬剔除(无半透明淡出),避免远景地形变透明
pm->set_visibility_range_end(terrain_patch_view);
pm->set_visibility_range_fade_mode(GeometryInstance3D::VISIBILITY_RANGE_FADE_DISABLED);
}
croot->add_child(pm);
}
}
++chunks_built;
// 地形碰撞(W1 item 6):HeightMapShape3D129×129,格距 = CELL_M(缩放承载)
if (collision_enabled) {
const int N = fmt::TerrainMesh::VERTS_XY; // 129
PackedFloat32Array hd;
hd.resize(N * N);
for (int j = 0; j < N; ++j)
for (int i = 0; i < N; ++i)
hd[j * N + i] = tmesh.positions[(j * N + i) * 3 + 1]; // Godot Y
Ref<HeightMapShape3D> hs;
hs.instantiate();
hs->set_map_width(N);
hs->set_map_depth(N);
hs->set_map_data(hd);
StaticBody3D *sb = memnew(StaticBody3D);
sb->set_name("TerrainBody");
CollisionShape3D *cs = memnew(CollisionShape3D);
cs->set_shape(hs);
sb->add_child(cs);
croot->add_child(sb);
const double CM = fmt::m2coord::CELLSCALE * fmt::m2coord::CM_TO_M; // 2m
const double X0 = double(tx) * fmt::m2coord::CHUNK_CM * fmt::m2coord::CM_TO_M;
const double Z0 = double(ty) * fmt::m2coord::CHUNK_CM * fmt::m2coord::CM_TO_M;
// HeightMapShape 以中心为原点、格距 1 -> 缩放到 CM,平移到区块中心
Transform3D t;
t.basis.scale(Vector3(CM, 1, CM));
t.origin = Vector3(X0 + (N - 1) * 0.5 * CM, 0, Z0 + (N - 1) * 0.5 * CM);
sb->set_transform(t);
}
// 水面(water.wtr
if (water_enabled) {
fmt::WaterMap wm;
std::string we;
if (fmt::load_water_map(dir + "/water.wtr", wm, &we) && wm.layer_count > 0 && resolver) {
auto pieces = build_chunk_water(wm, *hm, tx, ty, setting.height_scale, *resolver);
for (auto &p : pieces) {
if (!p.mesh.is_valid())
continue;
MeshInstance3D *w = memnew(MeshInstance3D);
w->set_name("Water");
w->set_mesh(p.mesh);
croot->add_child(w);
++water_pieces;
}
}
}
Chunk ck;
ck.tx = tx;
ck.ty = ty;
ck.hm = hm;
ck.am = am;
ck.root = croot;
if (objects_enabled && registry_ok && resolver)
place_chunk_objects(tx, ty, croot, ck.objects, ck.trees);
objects_placed += ck.objects;
trees_placed += ck.trees;
chunks.push_back(std::move(ck));
return true;
}
void Metin2World::place_chunk_objects(int tx, int ty, Node3D *root, int &n_obj, int &n_tree) {
n_obj = 0;
n_tree = 0;
const std::string mdir = std::string(map_dir().utf8().get_data());
// 树按 treefile 分组 -> 每组一个 MultiMeshInstance3D
struct TreeGroup {
std::vector<Transform3D> xforms;
};
std::map<std::string, TreeGroup> tree_groups;
{
fmt::AreaData ad;
std::string e;
if (!fmt::parse_area_data_file(
mdir + "/" + fmt::m2coord::tile_dir(tx, ty) + "/areadata.txt", ad, &e))
return;
for (const fmt::AreaObject &o : ad.objects) {
const fmt::Property *p = registry.find(o.crc);
if (!p) {
++objects_skipped;
continue;
}
// areadata position = 地图全局 cm(W0 结论修正:不是区块本地!x 正/东,y 负/南)。
const double mx = o.x;
const double my = o.y;
const double mz = o.z + o.height_bias;
fmt::m2coord::Vec3 g = fmt::m2coord::position_to_godot(mx, my, mz);
if (p->type == fmt::PropertyType::Tree) {
// 原客户端 Area -> Forest::CreateInstance 在地图放置路径只设置位置;
// 不给每实例添加随机 yaw/scale。树种差异由 treefile 对应的共享 mesh 承载。
std::string tf = p->get("treefile");
if (tf.empty()) {
++objects_skipped;
continue;
}
Basis b;
tree_groups[tf].xforms.push_back(Transform3D(b, Vector3(g.x, g.y, g.z)));
continue;
}
std::string model;
if (p->type == fmt::PropertyType::Building)
model = p->get("buildingfile");
else if (p->type == fmt::PropertyType::DungeonBlock)
model = p->get("dungeonblockfile");
else {
++objects_skipped; // Effect / Ambience 不影响 R1 画面
continue;
}
if (model.empty()) {
++objects_skipped;
continue;
}
std::string rp = resolver->resolve(model, nullptr);
if (rp.empty()) {
++objects_missing_model;
continue;
}
Ref<godot::ArrayMesh> mesh = get_static_mesh(rp, *resolver, static_cache);
if (!mesh.is_valid()) {
++objects_missing_model;
continue;
}
// areadata yaw#pitch#roll 已共轭到 Godot 空间(roll = 朝向 -> 绕 Godot +Y)。
fmt::m2coord::Mat3 r = fmt::m2coord::object_basis_godot(o.yaw, o.pitch, o.roll);
Basis place_basis(
Vector3(r.m[0], r.m[3], r.m[6]),
Vector3(r.m[1], r.m[4], r.m[7]),
Vector3(r.m[2], r.m[5], r.m[8]));
Transform3D place(place_basis, Vector3(g.x, g.y, g.z));
// mesh 顶点是 gr2 原始 cm / Z-up -> 本地再套 make_convcm->m + Z-up->Y-up
Transform3D xform = place * make_conv(0.01f, false);
MeshInstance3D *mi = memnew(MeshInstance3D);
mi->set_name(String(p->name.c_str()) + "_" + String::num_uint64(o.crc));
mi->set_mesh(mesh);
mi->set_transform(xform);
// ShadowFlag: 客户端把 isShadowFlag 物体丢进动态阴影贴图(= 我们的实时投影);
// 其余物体的阴影只存在于烘焙 shadowmap.dds。__static_shadows 可强制全部实时投。
if (p->get("shadowflag") != "1" && !static_shadows)
mi->set_cast_shadows_setting(GeometryInstance3D::SHADOW_CASTING_SETTING_OFF);
// portal ids 先存进 metaR1 不做室内裁剪)
if (!o.portal_ids.empty()) {
Array pids;
for (int pid : o.portal_ids)
pids.push_back(pid);
mi->set_meta("portal_ids", pids);
}
// .mdatr 静态碰撞:未实现。有同名 .mdatr 的计数上报(SHINSOO §9-W3 第 7 项)
{
String md = String(rp.c_str()).get_basename() + ".mdatr";
if (FileAccess::file_exists(md))
++objects_mdatr_pending;
}
root->add_child(mi);
// §8.2/§8.3: 盒碰撞体(层 2 = 静态遮挡物)。相机用它做防穿 + 遮挡淡出。
// 盒尺寸/中心直接算到「米 / Y-up」,body 只带 place(旋转+平移,无 conv),
// 避免把碰撞形状放在 0.01 缩放节点下(Godot 缩放 shape 不稳)。
if (collision_enabled) {
AABB ab = mesh->get_aabb(); // gr2 原始 cm / Z-up
if (ab.size.length() > 0.001f) {
Vector3 c_cm = ab.position + ab.size * 0.5f; // 中心 cm Z-up
StaticBody3D *body = memnew(StaticBody3D);
body->set_collision_layer(2);
body->set_collision_mask(0);
body->set_transform(place); // 世界旋转 + 平移
CollisionShape3D *cs = memnew(CollisionShape3D);
Ref<BoxShape3D> box;
box.instantiate();
// Z-up cm -> Y-up m(sx, sz, sy) * 0.01
box->set_size(Vector3(ab.size.x, ab.size.z, ab.size.y) * 0.01f);
cs->set_shape(box);
cs->set_position(Vector3(c_cm.x, c_cm.z, -c_cm.y) * 0.01f);
body->add_child(cs);
body->set_meta("occ_mesh", mi); // §8.2 相机淡出用
root->add_child(body);
}
}
++n_obj;
}
}
// Tree proxy:每 treefile 一个共享 mesh + MultiMeshInstance3D。
// 当前从 .spt 嗅探真实 bark/composite atlas;几何仍等待离线 SpeedTree exporter。
for (auto &kv : tree_groups) {
if (kv.second.xforms.empty())
continue;
Ref<godot::ArrayMesh> tmesh = get_tree_proxy_mesh(kv.first, *resolver, 12.0f);
Ref<MultiMesh> mm;
mm.instantiate();
mm->set_transform_format(MultiMesh::TRANSFORM_3D);
mm->set_mesh(tmesh);
mm->set_instance_count((int)kv.second.xforms.size());
for (int i = 0; i < (int)kv.second.xforms.size(); ++i)
mm->set_instance_transform(i, kv.second.xforms[i]);
MultiMeshInstance3D *mmi = memnew(MultiMeshInstance3D);
mmi->set_name(String("Trees_") + String(kv.first.c_str()).get_file().get_basename());
mmi->set_multimesh(mm);
// §3.3: 树影已烘焙进 shadowmap.dds;proxy 几何的实时投影形状也不对 -> 默认不投。
mmi->set_cast_shadows_setting(tree_shadows ? GeometryInstance3D::SHADOW_CASTING_SETTING_ON
: GeometryInstance3D::SHADOW_CASTING_SETTING_OFF);
mmi->set_visibility_range_end(420.0f); // 远处树剔除(W8 打磨)
mmi->set_visibility_range_end_margin(60.0f); // 淡出过渡
root->add_child(mmi);
n_tree += (int)kv.second.xforms.size();
++tree_species;
}
}
bool Metin2World::load_map() {
unload_map();
std::string err;
if (!fmt::parse_map_setting_file(
std::string(map_dir().utf8().get_data()) + "/setting.txt", setting, &err)) {
last_error = String("setting.txt: ") + err.c_str();
setting_ok = false;
UtilityFunctions::push_error(String("[Metin2World] ") + last_error);
return false;
}
setting_ok = true;
// splat 前置:TextureSet + AssetResolver(整盘扫描,一次)
splat_ready = false;
if (splat_enabled) {
std::string ts_rel = setting.texture_set;
for (char &c : ts_rel) {
if (c == '\\')
c = '/';
c = (char)std::tolower((unsigned char)c);
}
const std::string root = std::string(assets_root.utf8().get_data());
std::string tse;
if (fmt::parse_texture_set_file(root + "/textureset/" + ts_rel, texture_set, &tse)) {
resolver = std::make_shared<fmt::AssetResolver>();
std::string re;
// asset_index.txt 存在就装载(PCK/移动端必走),否则扫盘(桌面开发)。
if (resolver->build_or_load(root, fmt::AssetResolver::default_priority(), &re)) {
splat_ready = true;
} else {
UtilityFunctions::push_warning(String("[Metin2World] AssetResolver: ") + re.c_str());
resolver.reset();
}
} else {
UtilityFunctions::push_warning(String("[Metin2World] TextureSet: ") + tse.c_str());
}
}
// Property CRC 注册表(一次;splat 已建 resolver
registry_ok = false;
if (objects_enabled) {
const std::string root(assets_root.utf8().get_data());
std::string re;
// resolver 已建(splat 阶段):用它的文件清单,避免再 std::filesystem 扫盘
// PCK 里扫不了)。没 resolver 时退回目录递归。
bool ok = resolver
? registry.scan_list(root, resolver->all_rel(), &re)
: registry.scan(root + "/Property", &re);
if (ok)
registry_ok = true;
else
UtilityFunctions::push_warning(String("[Metin2World] Property scan: ") + re.c_str());
}
const double t0 = Time::get_singleton()->get_ticks_usec();
if (load_radius < 0) {
// 非流式:一次全建
for (int tx = 0; tx < setting.map_size_x; ++tx)
for (int ty = 0; ty < setting.map_size_y; ++ty)
build_chunk(tx, ty);
} else {
// 流式:把 focus 半径内的区块入队,_process 逐帧建
stream_update();
// 首帧同步建完队列,避免第一帧空场景
while (!stream_queue.empty()) {
auto [tx, ty] = stream_queue.front();
stream_queue.erase(stream_queue.begin());
build_chunk(tx, ty);
}
}
// .msenv -> 光照 / 天空 / 雾 / 色调
env_ok = false;
if (env_enabled && !setting.environment.empty()) {
std::string vp = std::string("d:/ymir work/environment/") + setting.environment;
std::string rp = resolver ? resolver->resolve(vp, nullptr) : std::string();
if (rp.empty())
rp = std::string(assets_root.utf8().get_data()) +
"/ETC/ymir work/environment/" + setting.environment;
// 小写文件名
for (size_t i = rp.rfind('/') + 1; i < rp.size(); ++i)
rp[i] = (char)std::tolower((unsigned char)rp[i]);
std::string ee;
if (fmt::parse_environment_file(rp, env, &ee)) {
apply_environment(env, this);
env_ok = true;
} else {
UtilityFunctions::push_warning(String("[Metin2World] .msenv: ") + ee.c_str());
}
}
build_ms = (Time::get_singleton()->get_ticks_usec() - t0) / 1000.0;
UtilityFunctions::print(String("[Metin2World] ") + map_path + " loaded: " +
String::num_int64(chunks_built) + "/" +
String::num_int64(setting.map_size_x * setting.map_size_y) + " chunks, " +
String::num_int64(chunks_splatted) + " splatted, " +
String::num_int64(objects_placed) + " objects (" +
String::num_int64(objects_missing_model) + " missing model), " +
String::num_int64(trees_placed) + " trees/" + String::num_int64(tree_species) + " spp, " +
String::num(build_ms, 1) + " ms");
return chunks_failed == 0;
}
void Metin2World::unload_chunk(int idx) {
if (idx < 0 || idx >= (int)chunks.size())
return;
Chunk &c = chunks[idx];
objects_placed -= c.objects;
trees_placed -= c.trees;
if (chunks_built > 0)
--chunks_built;
if (c.root)
c.root->queue_free();
chunks.erase(chunks.begin() + idx);
}
void Metin2World::stream_update() {
if (!setting_ok || load_radius < 0)
return;
// 卸载半径外
for (int i = (int)chunks.size() - 1; i >= 0; --i) {
if (std::abs(chunks[i].tx - focus_tx) > load_radius ||
std::abs(chunks[i].ty - focus_ty) > load_radius)
unload_chunk(i);
}
// 入队半径内且未加载 / 未在队列的
stream_queue.clear();
for (int dx = -load_radius; dx <= load_radius; ++dx)
for (int dy = -load_radius; dy <= load_radius; ++dy) {
int tx = focus_tx + dx, ty = focus_ty + dy;
if (tx < 0 || ty < 0 || tx >= setting.map_size_x || ty >= setting.map_size_y)
continue;
if (!chunk_at(tx, ty))
stream_queue.push_back({tx, ty});
}
}
void Metin2World::set_focus_position(double gx_m, double gz_m) {
const double mx = gx_m / fmt::m2coord::CM_TO_M;
const double my_abs = gz_m / fmt::m2coord::CM_TO_M;
int tx = std::max(0, std::min(setting.map_size_x - 1, int(mx / fmt::m2coord::CHUNK_CM)));
int ty = std::max(0, std::min(setting.map_size_y - 1, int(my_abs / fmt::m2coord::CHUNK_CM)));
if (tx == focus_tx && ty == focus_ty)
return;
focus_tx = tx;
focus_ty = ty;
if (load_radius >= 0)
stream_update();
}
void Metin2World::_process(double) {
for (int n = 0; n < stream_budget && !stream_queue.empty(); ++n) {
auto [tx, ty] = stream_queue.front();
stream_queue.erase(stream_queue.begin());
build_chunk(tx, ty);
}
}
void Metin2World::unload_map() {
while (!chunks.empty())
unload_chunk((int)chunks.size() - 1);
stream_queue.clear();
if (objects_root) {
objects_root->queue_free();
objects_root = nullptr;
}
for (const char *nm : {"Sun", "WorldEnv"})
if (Node *n = get_node_or_null(NodePath(nm)))
n->queue_free();
water_pieces = 0;
env_ok = false;
env = fmt::Environment{};
static_cache = StaticMeshCache{};
registry = fmt::PropertyRegistry{};
chunks_built = chunks_failed = chunks_splatted = 0;
objects_placed = objects_skipped = objects_missing_model = 0;
trees_placed = tree_species = 0;
objects_mdatr_pending = 0;
build_ms = 0;
splat_ready = registry_ok = false;
last_error = "";
}
Vector2 Metin2World::get_map_base_cm() const {
return Vector2((float)setting.base_position_x, (float)setting.base_position_y);
}
Vector2i Metin2World::get_map_size_tiles() const {
return Vector2i(setting.map_size_x, setting.map_size_y);
}
double Metin2World::sample_height(double gx_m, double gz_m) const {
if (!setting_ok)
return 0.0;
// Godot 米 -> Metin2 全局厘米。position_to_godot: gx = mx*0.01, gz = -my*0.01
const double mx_cm = gx_m / fmt::m2coord::CM_TO_M;
const double my_abs_cm = gz_m / fmt::m2coord::CM_TO_M; // = -my; 已是正的 "南向距离"
const int tx = int(mx_cm / fmt::m2coord::CHUNK_CM);
const int ty = int(my_abs_cm / fmt::m2coord::CHUNK_CM);
const Chunk *c = chunk_at(tx, ty);
if (!c || !c->hm)
return 0.0;
const double lx = mx_cm - double(tx) * fmt::m2coord::CHUNK_CM;
const double ly = my_abs_cm - double(ty) * fmt::m2coord::CHUNK_CM;
return fmt::terrain_height_at(*c->hm, lx, ly, setting.height_scale) * fmt::m2coord::CM_TO_M;
}
Ref<godot::Image> Metin2World::load_dds(const String &path) const {
mtgodot::Image d = mtgodot::dds_from_file(path);
if (!d.ok())
return Ref<godot::Image>();
PackedByteArray b;
b.resize((int64_t)d.rgba.size());
for (size_t i = 0; i < d.rgba.size(); ++i)
b[(int64_t)i] = d.rgba[i];
return godot::Image::create_from_data(d.w, d.h, false, godot::Image::FORMAT_RGBA8, b);
}
String Metin2World::chunk_dir(int tile_x, int tile_y) const {
return map_dir() + "/" + fmt::m2coord::tile_dir(tile_x, tile_y).c_str();
}
bool Metin2World::bake_asset_index(const String &out_path) {
const std::string root(assets_root.utf8().get_data());
fmt::AssetResolver r;
std::string err;
if (!r.build(root, fmt::AssetResolver::default_priority(), &err)) {
UtilityFunctions::push_error(String("[Metin2World] bake_asset_index build: ") + err.c_str());
return false;
}
const std::string idx = r.save_index();
Ref<FileAccess> f = FileAccess::open(out_path, FileAccess::WRITE);
if (f.is_null()) {
UtilityFunctions::push_error(String("[Metin2World] bake_asset_index: cannot write ") + out_path);
return false;
}
f->store_buffer(reinterpret_cast<const uint8_t *>(idx.data()), (int64_t)idx.size());
f->close();
UtilityFunctions::print(String("[Metin2World] asset_index: ") +
String::num_int64((int64_t)r.files_indexed) + " files -> " + out_path);
return true;
}
int Metin2World::sample_attribute(double gx_m, double gz_m) const {
if (!setting_ok)
return 0;
const double mx_cm = gx_m / fmt::m2coord::CM_TO_M;
const double my_abs_cm = gz_m / fmt::m2coord::CM_TO_M;
const int tx = int(mx_cm / fmt::m2coord::CHUNK_CM);
const int ty = int(my_abs_cm / fmt::m2coord::CHUNK_CM);
const Chunk *c = chunk_at(tx, ty);
if (!c || !c->am)
return 0;
// ATTRMAP 256x256 / 区块 25600cm -> 100cm/texel
const double lx = mx_cm - double(tx) * fmt::m2coord::CHUNK_CM;
const double ly = my_abs_cm - double(ty) * fmt::m2coord::CHUNK_CM;
int ax = int(lx / 100.0), ay = int(ly / 100.0);
if (ax < 0 || ay < 0 || ax >= fmt::ATTRMAP_XY || ay >= fmt::ATTRMAP_XY)
return 0;
return c->am->data[size_t(ay) * fmt::ATTRMAP_XY + ax];
}
Dictionary Metin2World::get_load_report() const {
Dictionary d;
d["map_path"] = map_path;
d["setting_ok"] = setting_ok;
d["map_size_x"] = setting.map_size_x;
d["map_size_y"] = setting.map_size_y;
d["cell_scale"] = setting.cell_scale;
d["height_scale"] = setting.height_scale;
d["chunks_built"] = chunks_built;
d["chunks_failed"] = chunks_failed;
d["chunks_splatted"] = chunks_splatted;
d["water_pieces"] = water_pieces;
d["splat_ready"] = splat_ready;
d["registry_ok"] = registry_ok;
d["registry_crcs"] = (int)registry.by_crc.size();
d["env_ok"] = env_ok;
d["fog_level"] = env.fog.fog_level;
d["objects_placed"] = objects_placed;
d["objects_skipped"] = objects_skipped;
d["objects_missing_model"] = objects_missing_model;
d["trees_placed"] = trees_placed;
d["tree_species"] = tree_species;
d["objects_mdatr_pending"] = objects_mdatr_pending; // 有 .mdatr 但未建碰撞
d["static_meshes"] = static_cache.loaded;
d["build_ms"] = build_ms;
d["resident_chunks"] = (int)chunks.size();
d["stream_queue"] = (int)stream_queue.size();
d["load_radius_tiles"] = load_radius;
d["last_error"] = last_error;
return d;
}
Dictionary Metin2World::get_perf() const {
Dictionary d;
Performance *pf = Performance::get_singleton();
d["fps"] = pf->get_monitor(Performance::TIME_FPS);
d["process_ms"] = pf->get_monitor(Performance::TIME_PROCESS) * 1000.0;
d["frame_ms"] = pf->get_monitor(Performance::TIME_PROCESS) * 1000.0 +
pf->get_monitor(Performance::TIME_PHYSICS_PROCESS) * 1000.0;
d["draw_calls"] = pf->get_monitor(Performance::RENDER_TOTAL_DRAW_CALLS_IN_FRAME);
d["primitives"] = pf->get_monitor(Performance::RENDER_TOTAL_PRIMITIVES_IN_FRAME);
d["video_mem_mb"] = pf->get_monitor(Performance::RENDER_VIDEO_MEM_USED) / (1024.0 * 1024.0);
d["tex_mem_mb"] = pf->get_monitor(Performance::RENDER_TEXTURE_MEM_USED) / (1024.0 * 1024.0);
d["objects_3d"] = pf->get_monitor(Performance::RENDER_TOTAL_OBJECTS_IN_FRAME);
d["nodes"] = pf->get_monitor(Performance::OBJECT_NODE_COUNT);
d["resident_chunks"] = (int)chunks.size();
d["stream_queue"] = (int)stream_queue.size();
return d;
}
} // namespace mtgodot
+159
View File
@@ -0,0 +1,159 @@
#pragma once
#include <godot_cpp/classes/image.hpp>
#include <godot_cpp/classes/node3d.hpp>
#include <godot_cpp/classes/ref.hpp>
#include <godot_cpp/variant/dictionary.hpp>
#include <godot_cpp/variant/string.hpp>
#include <asset_resolver.h>
#include <environment.h>
#include <map_setting.h>
#include <property.h>
#include <terrain_files.h>
#include <texture_set.h>
#include <memory>
#include <vector>
#include "static_object.h"
namespace godot {
class MeshInstance3D;
}
namespace mtgodot {
// W1 —— 加载一张 Metin2 户外地图并渲染灰色高度地形。
// SHINSOO-WORLD-RENDERING.md §8Metin2World API 契约)/ §9-W1。
//
// Metin2World (Node3D)
// └─ Terrain_000000 (MeshInstance3D) …每区块一个
//
// 后续阶段往这里加 splat 材质(W2)、静态对象(W3)、树(W4)、环境(W5)…
class Metin2World : public godot::Node3D {
GDCLASS(Metin2World, godot::Node3D)
public:
Metin2World();
~Metin2World() override;
void _ready() override;
void _process(double delta) override;
void set_assets_root(const godot::String &p);
godot::String get_assets_root() const { return assets_root; }
void set_map_path(const godot::String &p);
godot::String get_map_path() const { return map_path; }
void set_load_radius_tiles(int r) { load_radius = r; }
int get_load_radius_tiles() const { return load_radius; }
void set_focus_tile(godot::Vector2i t);
godot::Vector2i get_focus_tile() const { return godot::Vector2i(focus_tx, focus_ty); }
void set_auto_load(bool v) { auto_load = v; }
bool get_auto_load() const { return auto_load; }
void set_splat_enabled(bool v) { splat_enabled = v; }
bool get_splat_enabled() const { return splat_enabled; }
void set_terrain_patches(int n) { terrain_patches = n; }
int get_terrain_patches() const { return terrain_patches; }
void set_objects_enabled(bool v) { objects_enabled = v; }
bool get_objects_enabled() const { return objects_enabled; }
void set_env_enabled(bool v) { env_enabled = v; }
bool get_env_enabled() const { return env_enabled; }
void set_water_enabled(bool v) { water_enabled = v; }
bool get_water_enabled() const { return water_enabled; }
// §3.3: static building/tree shadows are already baked into shadowmap.dds, so
// they don't cast realtime sun shadows by default (avoids double-darkening).
// Only shadowflag=1 buildings and dynamic actors cast realtime. Flip these on
// for a modernized look.
void set_tree_shadows(bool v) { tree_shadows = v; }
bool get_tree_shadows() const { return tree_shadows; }
void set_static_shadows(bool v) { static_shadows = v; }
bool get_static_shadows() const { return static_shadows; }
void set_stream_budget(int v) { stream_budget = v < 1 ? 1 : v; }
int get_stream_budget() const { return stream_budget; }
bool load_map();
void unload_map();
// 流式:把关注点设到某全局米坐标;load_radius_tiles >= 0 时按 3×3(radius) 装/卸区块。
void set_focus_position(double gx_m, double gz_m);
godot::Dictionary get_perf() const; // fps / frame ms / draw calls / prims / vram / 节点数
// 全局米坐标(Godot 空间)下的地表高度;地图外返回 0。
godot::Vector2 get_map_base_cm() const; // setting.txt BasePosition, in cm
godot::Vector2i get_map_size_tiles() const;
double sample_height(double gx_m, double gz_m) const;
// attr.atr 属性字节(bit0=BLOCK, bit1=WATER…);地图外返回 0。
int sample_attribute(double gx_m, double gz_m) const;
bool is_blocked(double gx_m, double gz_m) const { return (sample_attribute(gx_m, gz_m) & 1) != 0; }
godot::Dictionary get_load_report() const;
// 便捷:解一张 DDS 为 Image(HUD 小地图等用;Godot 原生不支持 .dds)。
godot::Ref<godot::Image> load_dds(const godot::String &path) const;
// map_path 下某区块目录的绝对路径(HUD 找 minimap.dds 用)。
godot::String chunk_dir(int tile_x, int tile_y) const;
// 构建期用:扫 assets_root 建 AssetResolver 索引,写到 out_pathasset_index.txt)。
// 打进 PCK 后移动端 build_or_load() 直接装载,不再 std::filesystem 扫盘。
bool bake_asset_index(const godot::String &out_path);
protected:
static void _bind_methods();
private:
godot::String assets_root;
godot::String map_path = "OutdoorA1/metin2_map_a1";
int load_radius = -1; // <0 = 全图
int focus_tx = 0, focus_ty = 0;
bool auto_load = true;
bool splat_enabled = true;
// §3.5: 每区块地形拆成 N×N patch(各自 MeshInstance),启用逐 patch 视锥剔除 +
// visibility_range 远距整片剔除。1 = 不拆(旧行为)。必须整除 128。
int terrain_patches = 4;
// patch 超过这个距离整片不画(米)。默认 3500 覆盖全图+俯视调试,实际增益来自
// 逐 patch 视锥剔除;游戏内可调低省远景地形。
float terrain_patch_view = 3500.0f;
bool objects_enabled = true;
bool env_enabled = true;
bool water_enabled = true;
bool collision_enabled = true;
bool tree_shadows = false; // trees: baked in shadowmap.dds -> no realtime cast
bool static_shadows = false; // shadowflag=0/empty buildings: same
int water_pieces = 0;
fmt::MapSetting setting;
fmt::TextureSet texture_set;
fmt::Environment env;
bool env_ok = false;
std::shared_ptr<fmt::AssetResolver> resolver;
fmt::PropertyRegistry registry;
StaticMeshCache static_cache;
bool setting_ok = false;
bool splat_ready = false;
bool registry_ok = false;
godot::String last_error;
int chunks_built = 0, chunks_failed = 0, chunks_splatted = 0;
int objects_placed = 0, objects_skipped = 0, objects_missing_model = 0;
int trees_placed = 0, tree_species = 0;
int objects_mdatr_pending = 0;
godot::Node3D *objects_root = nullptr;
double build_ms = 0;
struct Chunk {
int tx = 0, ty = 0;
std::shared_ptr<fmt::HeightMap> hm;
std::shared_ptr<fmt::AttrMap> am;
godot::Node3D *root = nullptr; // 该区块的全部场景节点(terrain + water + 对象 + 树)
int objects = 0, trees = 0;
};
std::vector<Chunk> chunks;
std::vector<std::pair<int, int>> stream_queue; // 待建区块
int stream_budget = 1; // 每帧最多建几个区块(streaming 时)
godot::String map_dir() const;
bool build_chunk(int tx, int ty);
void place_chunk_objects(int tx, int ty, godot::Node3D *root, int &n_obj, int &n_tree);
void unload_chunk(int idx);
void stream_update();
const Chunk *chunk_at(int tx, int ty) const;
};
} // namespace mtgodot
+99
View File
@@ -0,0 +1,99 @@
#pragma once
// AuthClient — drives the Metin2 auth-server exchange on top of NetStream:
// connect -> (base does KX handshake) -> GC_PHASE(PHASE_AUTH)
// -> send CG_LOGIN3{id, pwd} -> GC_AUTH_SUCCESS{login_key} | GC_LOGIN_FAILURE
// On success `login_key()` is the ticket to hand the game server via CG_LOGIN2.
#include "net_stream.h"
#include <cstring>
#include <string>
namespace mtnet {
class AuthClient : public NetStream {
public:
AuthClient(std::string id, std::string pw)
: m_id(std::move(id)), m_pw(std::move(pw)) {}
bool done() const { return m_done; }
bool success() const { return m_success; }
uint32_t login_key() const { return m_login_key; }
const std::string &fail_reason() const { return m_fail_reason; }
bool handshaked() const { return cipher_active(); }
bool sent_login() const { return m_sent_login; }
protected:
void on_phase(uint8_t phase) override {
if (phase == PHASE_AUTH) {
CGLogin3 p{};
p.header = CG_LOGIN3;
p.length = sizeof(p);
std::strncpy(p.name, m_id.c_str(), sizeof(p.name) - 1);
std::strncpy(p.pwd, m_pw.c_str(), sizeof(p.pwd) - 1);
send_packet(&p, sizeof(p));
m_sent_login = true;
} else if (phase == PHASE_CLOSE) {
m_done = true;
}
}
bool on_packet(uint16_t header, uint16_t len) override {
if (header == GC_AUTH_SUCCESS) {
GCAuthSuccess p{};
if (!recv_bytes(&p, sizeof(p))) {
return false;
}
m_success = (p.result != 0);
m_login_key = p.login_key;
if (!m_success) {
m_fail_reason = "AUTH_SUCCESS result=0";
}
m_done = true;
return true;
}
if (header == GC_LOGIN_FAILURE) {
// body is a short reason string in this fork; read + stringify
char buf[128] = {0};
DynHeader dh{};
peek_bytes(&dh, sizeof(dh));
uint16_t body = len > sizeof(DynHeader) ? len - (uint16_t)sizeof(DynHeader) : 0;
if (body > sizeof(buf) - 1) {
body = sizeof(buf) - 1;
}
// consume the whole packet
uint8_t discard[512];
if (len <= sizeof(discard)) {
recv_bytes(discard, len);
if (body > 0) {
std::memcpy(buf, discard + sizeof(DynHeader), body);
}
} else {
drop_recv();
}
m_fail_reason = std::string("LOGIN_FAILURE ") + buf;
m_done = true;
return true;
}
// Unknown auth-phase packet: log its header, consume, keep going.
uint8_t discard[1024];
if (len <= sizeof(discard)) {
recv_bytes(discard, len);
} else {
drop_recv();
}
return true;
}
void on_disconnect() override { m_done = true; }
private:
std::string m_id, m_pw;
bool m_done = false;
bool m_success = false;
bool m_sent_login = false;
uint32_t m_login_key = 0;
std::string m_fail_reason;
};
} // namespace mtnet
+78
View File
@@ -0,0 +1,78 @@
#pragma once
// Minimal growable byte buffer with a read cursor — the recv/send staging area
// for NetStream. Mirrors what the client's ByteBuffer does: append at the write
// end, consume from the read end, compact when the read cursor drifts.
#include <cstdint>
#include <cstring>
#include <vector>
namespace mtnet {
class ByteBuffer {
public:
void clear() {
m_buf.clear();
m_rpos = 0;
}
size_t readable() const { return m_buf.size() - m_rpos; }
bool has(size_t n) const { return readable() >= n; }
const uint8_t *read_ptr() const { return m_buf.data() + m_rpos; }
// copy without consuming
bool peek(void *dst, size_t n) const {
if (readable() < n) {
return false;
}
std::memcpy(dst, m_buf.data() + m_rpos, n);
return true;
}
// advance the read cursor
void discard(size_t n) {
m_rpos += n;
if (m_rpos > m_buf.size()) {
m_rpos = m_buf.size();
}
if (m_rpos == m_buf.size()) {
m_buf.clear();
m_rpos = 0;
} else if (m_rpos > (1u << 16) && m_rpos * 2 > m_buf.size()) {
m_buf.erase(m_buf.begin(), m_buf.begin() + static_cast<std::ptrdiff_t>(m_rpos));
m_rpos = 0;
}
}
bool read(void *dst, size_t n) {
if (!peek(dst, n)) {
return false;
}
discard(n);
return true;
}
void write(const void *src, size_t n) {
const auto *p = static_cast<const uint8_t *>(src);
m_buf.insert(m_buf.end(), p, p + n);
}
// raw append region (used by recv()): reserve `n`, get a pointer, then commit
uint8_t *reserve_write(size_t n) {
m_buf.resize(m_buf.size() + n);
return m_buf.data() + m_buf.size() - n;
}
void commit_write(size_t n, size_t reserved) {
if (n < reserved) {
m_buf.resize(m_buf.size() - (reserved - n));
}
}
// mutable view of unread bytes (for decrypt-in-place of already-buffered data)
uint8_t *mutable_unread() { return m_buf.data() + m_rpos; }
private:
std::vector<uint8_t> m_buf;
size_t m_rpos = 0;
};
} // namespace mtnet
File diff suppressed because it is too large Load Diff
+925
View File
@@ -0,0 +1,925 @@
#pragma once
// EntityStore — headless model of the networked world: turns game-phase packets
// (GC_MAIN_CHARACTER / GC_CHARACTER_ADD[2] / GC_CHARACTER_DEL / GC_MOVE /
// GC_CHAT / GC_CHARACTER_UPDATE / GC_FLY_TARGETING) into entity state +
// interpolated positions.
// No Godot / engine dependency (unit-testable). A bridge layer polls
// drain_changes() / drain_chat() and mirrors entities into Metin2World.
//
// Positions are Metin2 cm (server space). func mirrors CInstanceBase::FUNC_*.
#include "wire.h"
#include <cstdint>
#include <string>
#include <unordered_map>
#include <vector>
namespace mtnet {
enum : uint8_t {
FUNC_WAIT = 0,
FUNC_MOVE = 1,
FUNC_ATTACK = 2,
FUNC_COMBO = 3,
FUNC_MOB_SKILL = 4,
FUNC_EMOTION = 5,
FUNC_SKILL = 0x80,
};
struct Entity {
uint32_t vid = 0;
uint16_t race = 0;
uint8_t ch_type = 0; // CHRTYPE: 0 PC, 1 NPC, 2 MONSTER, 3 STONE, 4 WARP, ...
std::string name;
uint16_t parts[CHR_EQUIPPART_NUM] = {0, 0, 0, 0};
bool is_main = false;
float x = 0, y = 0, z = 0; // current (interpolated) position, cm
float angle = 0;
uint8_t func = FUNC_WAIT;
uint8_t position = 0;
uint8_t walk_mode = 0; // 0 walk, 1 run (server's WALKMODE_*)
uint32_t fly_target_vid = 0;
int32_t fly_target_x = 0, fly_target_y = 0;
bool fly_target_set = false;
uint16_t moving_speed = 0;
bool moving = false;
float sx = 0, sy = 0, tx = 0, ty = 0;
uint32_t move_start_ms = 0;
uint32_t move_dur_ms = 0;
// combat / status (0 = unknown until the server sends it)
uint8_t attack_speed = 0; // GC_CHARACTER_ADD[2]/UPDATE bAttackSpeed (x100)
int32_t hp = 0, max_hp = 0;
int32_t sp = 0, max_sp = 0;
int32_t level = 0;
bool dead = false;
bool stunned = false;
uint32_t mount_vnum = 0; // 0 = on foot (GC_MOUNT / GC_CHAR_ADD_INFO)
int32_t guild = 0;
int16_t alignment = 0;
uint8_t pk_mode = 0;
};
// --- P9 world systems ----------------------------------------------------
// GC_WARP — teleport target. same_server() -> just move the player; otherwise
// the caller must reconnect to addr:port (P10).
struct WarpCue {
int32_t x = 0, y = 0;
int32_t addr = 0;
uint16_t port = 0;
bool same_server() const { return addr == 0; }
};
// One atlas/minimap NPC entry (GC_NPC_POSITION).
struct NPCMark {
uint8_t type = 0;
uint32_t vnum = 0;
std::string name;
int32_t x = 0, y = 0;
};
// A quest/world marker (GC_TARGET_CREATE/UPDATE/DELETE).
struct WorldMarker {
int32_t id = 0;
std::string name;
uint32_t vid = 0;
uint8_t type = 0; // CREATE_TARGET_TYPE_*
int32_t x = 0, y = 0;
};
// One floating damage number (GC_DAMAGE_INFO).
struct DamageEvent {
uint32_t vid = 0;
uint8_t flag = 0; // DAMAGE_* bits
int32_t amount = 0;
};
// A one-shot combat/emote motion (GC_MOTION).
struct MotionEvent {
uint32_t vid = 0;
uint32_t victim_vid = 0;
uint16_t motion = 0;
};
// A mining animation broadcast (GC_DIG_MOTION).
struct DigMotionEvent {
uint32_t vid = 0;
uint32_t target_vid = 0;
uint8_t count = 0;
};
struct FishingEvent {
uint8_t subheader = 0;
uint32_t info = 0;
uint8_t dir = 0;
};
struct DungeonEvent {
uint8_t subheader = 0;
int32_t x = 0;
int32_t y = 0;
bool has_destination = false;
};
struct LandArea {
uint32_t id = 0;
int32_t x = 0, y = 0;
int32_t width = 0, height = 0;
uint32_t guild_id = 0;
};
struct Observer {
uint32_t vid = 0;
int32_t x = 0, y = 0; // server centimetres
};
struct ObserverEvent {
enum Kind { Add, Remove, Move } kind = Add;
uint32_t vid = 0;
int32_t x = 0, y = 0;
};
// A buff/debuff on the local player (GC_AFFECT_ADD/REMOVE).
struct Affect {
uint32_t type = 0;
uint8_t point_idx = 0;
int32_t value = 0;
uint32_t flag = 0;
int32_t duration = 0;
};
struct AffectChange {
uint32_t type = 0;
bool added = false;
};
// A one-shot special/specific effect to play on an entity (GC_SPECIAL/SPECIFIC_EFFECT).
struct EffectCue {
uint32_t vid = 0;
int32_t special = -1; // >=0 for GC_SPECIAL_EFFECT (built-in id)
std::string file; // set for GC_SPECIFIC_EFFECT (.mse path)
};
// A projectile (GC_CREATE_FLY): type, from vid, to vid.
struct FlyCue {
uint8_t type = 0;
uint32_t start_vid = 0;
uint32_t end_vid = 0;
};
// A server broadcast that sets or appends a shooter's fly target.
struct FlyTargetCue {
uint32_t shooter_vid = 0;
uint32_t target_vid = 0;
int32_t x = 0;
int32_t y = 0;
bool append = false;
};
// An NPC dialog to render (GC_SCRIPT): skin + raw EventManager script text.
struct ScriptCue {
uint8_t skin = 0;
std::string text;
};
// A yes/no prompt (GC_QUEST_CONFIRM).
struct ConfirmCue {
std::string msg;
int32_t timeout = 0;
uint32_t request_pid = 0;
};
// One quest-log entry (GC_QUEST_INFO, flag-driven).
struct QuestInfo {
uint16_t index = 0;
uint8_t flag = 0;
bool begin = false;
std::string title;
std::string clock_name;
int32_t clock_value = 0;
std::string counter_name;
int32_t counter_value = 0;
std::string icon;
};
// One item in a slot (inventory / equipment). vnum 0 = empty.
struct Item {
uint32_t vnum = 0;
uint8_t count = 0;
uint32_t flags = 0;
uint32_t anti_flags = 0;
int32_t sockets[ITEM_SOCKET_SLOT_MAX_NUM] = {0, 0, 0};
ItemAttr attrs[ITEM_ATTRIBUTE_SLOT_MAX_NUM] = {};
bool empty() const { return vnum == 0; }
};
// Snapshot supplied by GC_VIEW_EQUIP when inspecting another character.
struct ViewedEquipment {
uint32_t vid = 0;
Item items[VIEW_EQUIP_WEAR_MAX_NUM] = {};
};
// --- P8 party (GC_PARTY_*) --------------------------------------------------
struct PartyMember {
uint32_t pid = 0;
uint32_t vid = 0; // 0 until GC_PARTY_LINK
std::string name;
uint8_t state = 0; // bit0 = leader (this fork's server)
uint8_t hp_pct = 0; // 0..100
int16_t affects[PARTY_AFFECT_SLOT_MAX_NUM] = {0};
bool leader() const { return (state & 1) != 0; }
};
// --- P8 messenger friend list (GC_MESSENGER) ------------------------------
struct Friend {
std::string name;
bool online = false;
};
// --- P8 NPC shop (GC_SHOP) ----------------------------------------------------
struct ShopEntry {
uint32_t vnum = 0;
uint32_t price = 0;
uint8_t count = 0;
uint8_t pos = 0; // slot index within its tab (0..SHOP_HOST_ITEM_MAX_NUM-1)
};
// One shelf/tab of a SHOP_GC_START_EX shop. A plain SHOP_GC_START shop is
// modelled as a single unnamed tab.
struct ShopTab {
std::string name;
uint8_t coin_type = 0;
std::vector<ShopEntry> items;
};
// --- P8 exchange / trade (GC_EXCHANGE) -------------------------------------
struct ExchangeSlot {
uint32_t vnum = 0;
uint8_t count = 0;
};
struct ExchangeState {
bool active = false;
uint32_t partner_vid = 0;
ExchangeSlot self_items[12] = {};
ExchangeSlot peer_items[12] = {};
int64_t self_gold = 0;
int64_t peer_gold = 0;
bool self_accept = false;
bool peer_accept = false;
};
// --- guild (GC_GUILD) --------------------------------------------------------
struct GuildMember {
uint32_t pid = 0;
uint8_t grade = 0;
bool is_general = false;
uint8_t job = 0;
uint8_t level = 0;
uint32_t offer = 0;
std::string name;
};
struct GuildGrade {
std::string name;
uint8_t auth = 0;
};
struct GuildState {
bool in_guild = false;
uint32_t id = 0;
std::string name;
uint8_t level = 0;
uint32_t exp = 0;
uint32_t gold = 0;
uint16_t member_count = 0;
uint16_t max_member_count = 0;
uint32_t master_pid = 0;
bool has_land = false;
};
// GUILD_GC_SKILL_INFO — the guild-skill page.
struct GuildSkillState {
bool valid = false;
uint8_t skill_point = 0;
uint8_t levels[GUILD_SKILL_MAX_NUM] = {};
uint16_t guild_point = 0;
uint16_t max_guild_point = 0;
};
// GUILD_GC_WAR — our current guild-war status against one opponent.
struct GuildWarStatus {
uint32_t opp_guild_id = 0;
uint8_t type = 0;
uint8_t state = GUILD_WAR_NONE;
};
// GUILD_GC_WAR_POINT — a scoreboard delta.
struct GuildWarScore {
uint32_t gain_guild_id = 0;
uint32_t opp_guild_id = 0;
int32_t point = 0;
};
// --- dragon-soul refine (GC_DRAGON_SOUL_REFINE) --------------------------
struct DragonSoulCue {
uint8_t sub_type = 0; // DS_SUB_* (OPEN / REFINE_SUCCEED / REFINE_FAIL_*)
uint8_t window = 0; // affected item position (success/fail)
uint16_t cell = 0;
};
// --- refine (GC_REFINE_INFORMATION) ----------------------------------------
struct RefineCue {
uint8_t type = 0;
uint8_t pos = 0; // inventory cell of the item
uint32_t src_vnum = 0;
uint32_t result_vnum = 0;
int32_t cost = 0;
int32_t prob = 0; // success %
struct Mat {
uint32_t vnum = 0;
int32_t count = 0;
} materials[5] = {};
uint8_t material_count = 0;
};
// An item lying in the world (GC_ITEM_GROUND_ADD).
struct GroundItem {
uint32_t vid = 0;
uint32_t vnum = 0;
float x = 0, y = 0, z = 0; // server cm
std::string owner;
};
// One inventory/equipment slot changed (window = WINDOW_INVENTORY / _EQUIPMENT).
struct InvChange {
uint8_t window = 0;
uint16_t cell = 0;
};
// A ground item appeared / vanished.
struct GroundChange {
uint32_t vid = 0;
bool added = false;
};
// "You picked up N x <vnum>" (GC_ITEM_GET) or "someone used <vnum>" (GC_ITEM_USE).
struct ItemEvent {
enum Kind { Get, Use };
Kind kind = Get;
uint32_t vnum = 0;
uint8_t count = 0;
std::string from; // GC_ITEM_GET only
};
struct PvpRelation {
uint32_t src_vid = 0;
uint32_t dst_vid = 0;
uint8_t mode = 0;
};
struct LoverInfo {
std::string name;
uint8_t love_point = 0;
bool valid = false;
};
// The local player's full stat array (GC_PLAYER_POINTS), indexed by EPointTypes.
struct PlayerPoints {
int32_t v[256] = {0};
int32_t hp() const { return v[POINT_HP]; }
int32_t max_hp() const { return v[POINT_MAX_HP]; }
int32_t sp() const { return v[POINT_SP]; }
int32_t max_sp() const { return v[POINT_MAX_SP]; }
int32_t level() const { return v[POINT_LEVEL]; }
int32_t exp() const { return v[POINT_EXP]; }
int32_t next_exp() const { return v[POINT_NEXT_EXP]; }
int32_t gold() const { return v[POINT_GOLD]; }
int32_t energy() const { return v[POINT_ENERGY]; }
int32_t energy_end_time() const { return v[POINT_ENERGY_END_TIME]; }
};
class EntityStore {
public:
enum class ChangeKind { Spawn, Despawn, Move, MainSet, Info };
struct Change {
ChangeKind kind;
uint32_t vid;
};
struct ChatMsg {
uint8_t type = 0; // EChatType; CHAT_TYPE_WHISPER for whispers
uint32_t vid = 0; // speaker vid (0 for whisper / system)
std::string text;
std::string from; // whisper sender name (empty otherwise)
uint8_t sub = 0; // whisper: WHISPER_TYPE_*
};
void set_now(uint32_t now_ms) { m_now = now_ms; }
// Feed one complete game-phase packet. `body` points at the packet start
// (header/length included); `len` == that length. Unknown headers ignored.
void apply(uint16_t header, const void *body, uint16_t len);
// Advance interpolation of moving entities to m_now.
void tick();
const Entity *get(uint32_t vid) const;
uint32_t main_vid() const { return m_main_vid; }
std::vector<uint32_t> vids() const;
size_t size() const { return m_ents.size(); }
// local player's full stat block; valid once GC_PLAYER_POINTS has arrived.
const PlayerPoints &points() const { return m_points; }
// local player's skill levels (index = skill id, 0..SKILL_MAX_NUM-1).
uint8_t skill_level(int id) const {
return (id >= 0 && id < SKILL_MAX_NUM) ? m_skills[id] : 0;
}
// 0 normal / 1 master / 2 grand master / 3 perfect master (GC_SKILL_LEVEL_NEW).
uint8_t skill_master(int id) const {
return (id >= 0 && id < SKILL_MAX_NUM) ? m_skill_master[id] : 0;
}
uint8_t skill_group() const { return m_skill_group; }
bool skill_group_dirty() {
bool d = m_skill_group_dirty;
m_skill_group_dirty = false;
return d;
}
bool skills_dirty() { bool d = m_skills_dirty; m_skills_dirty = false; return d; }
// local player's quickslots (GC_QUICKSLOT_*), 0..QUICKSLOT_MAX_NUM-1.
QuickSlot quickslot(int pos) const {
return (pos >= 0 && pos < QUICKSLOT_MAX_NUM) ? m_quickslots[pos] : QuickSlot{};
}
bool quickslots_dirty() { bool d = m_quickslots_dirty; m_quickslots_dirty = false; return d; }
std::vector<int> drain_cooldown_ends() {
auto v = std::move(m_cooldown_ends);
m_cooldown_ends.clear();
return v;
}
std::vector<FlyCue> drain_fly_cues() {
auto v = std::move(m_fly_cues);
m_fly_cues.clear();
return v;
}
std::vector<FlyTargetCue> drain_fly_target_cues() {
auto v = std::move(m_fly_target_cues);
m_fly_target_cues.clear();
return v;
}
std::vector<ScriptCue> drain_scripts() {
auto v = std::move(m_scripts);
m_scripts.clear();
return v;
}
std::vector<ConfirmCue> drain_confirms() {
auto v = std::move(m_confirms);
m_confirms.clear();
return v;
}
// vids of quest-log entries changed since last drain.
std::vector<uint16_t> drain_quest_changes() {
auto v = std::move(m_quest_changes);
m_quest_changes.clear();
return v;
}
const QuestInfo *quest(uint16_t index) const {
auto it = m_quests.find(index);
return it == m_quests.end() ? nullptr : &it->second;
}
std::vector<uint16_t> quest_indices() const {
std::vector<uint16_t> v;
v.reserve(m_quests.size());
for (auto &kv : m_quests) {
v.push_back(kv.first);
}
return v;
}
// currently-selected target's HP percent (GC_TARGET_INFO); 0 vid = none.
uint32_t target_vid() const { return m_target_vid; }
uint8_t target_hp_pct() const { return m_target_hp_pct; }
// --- P8 party ---
bool in_party() const { return !m_party.empty(); }
std::vector<uint32_t> party_pids() const {
std::vector<uint32_t> v;
v.reserve(m_party.size());
for (auto &kv : m_party) {
v.push_back(kv.first);
}
return v;
}
const PartyMember *party_member(uint32_t pid) const {
auto it = m_party.find(pid);
return it == m_party.end() ? nullptr : &it->second;
}
uint8_t party_distribute_mode() const { return m_party_mode; }
bool party_dirty() { bool d = m_party_dirty; m_party_dirty = false; return d; }
std::vector<uint32_t> drain_party_invites() {
auto v = std::move(m_party_invites);
m_party_invites.clear();
return v;
}
// --- P8 messenger ---
std::vector<Friend> friends() const {
std::vector<Friend> v;
v.reserve(m_friends.size());
for (auto &kv : m_friends) {
v.push_back(kv.second);
}
return v;
}
bool friends_dirty() { bool d = m_friends_dirty; m_friends_dirty = false; return d; }
// --- P8 NPC shop ---
bool shop_open() const { return m_shop_open; }
uint32_t shop_vid() const { return m_shop_vid; }
const std::vector<ShopEntry> &shop_items() const { return m_shop_items; }
// SHOP_GC_START_EX shelves; empty for a plain SHOP_GC_START shop (use shop_items()).
const std::vector<ShopTab> &shop_tabs() const { return m_shop_tabs; }
bool shop_dirty() { bool d = m_shop_dirty; m_shop_dirty = false; return d; }
std::vector<std::string> drain_shop_errors() {
auto v = std::move(m_shop_errors);
m_shop_errors.clear();
return v;
}
// --- P8 exchange ---
const ExchangeState &exchange() const { return m_exchange; }
bool exchange_dirty() { bool d = m_exchange_dirty; m_exchange_dirty = false; return d; }
// --- P8 safebox ---
bool safebox_open() const { return m_safebox_open; }
int safebox_size() const { return m_safebox_size; }
int64_t safebox_gold() const { return m_safebox_gold; }
const Item &safebox_slot(int cell) const;
bool safebox_dirty() { bool d = m_safebox_dirty; m_safebox_dirty = false; return d; }
// --- item-mall (창고몰) ---
bool mall_open() const { return m_mall_open; }
int mall_size() const { return m_mall_size; }
const Item &mall_slot(int cell) const;
bool mall_dirty() { bool d = m_mall_dirty; m_mall_dirty = false; return d; }
// --- cube (제작) ---
struct CubeResultEntry {
uint32_t vnum = 0;
int count = 0;
};
struct CubeMaterialSlot {
uint32_t vnum = 0;
int count = 0;
};
struct CubeRecipe { // one craftable output + its materials
uint32_t result_vnum = 0;
int result_count = 0;
int64_t gold = 0;
std::vector<std::vector<CubeMaterialSlot>> material_groups; // any-of groups
};
struct CubeState {
bool open = false;
uint32_t npc_vnum = 0;
int64_t need_gold = 0; // gold the current pending craft needs
uint32_t need_item_vnum = 0; // last "cube info" hint
int need_item_count = 0;
std::vector<CubeResultEntry> results; // r_list: what this NPC can make
std::vector<CubeRecipe> recipes; // m_info: materials per result
};
enum class CubeEvent { Opened, Closed, InfoChanged, Success, Fail };
const CubeState &cube() const { return m_cube; }
std::vector<CubeEvent> drain_cube_events() {
auto v = std::move(m_cube_events);
m_cube_events.clear();
return v;
}
// last Success payload (valid right after a CubeEvent::Success is drained)
CubeResultEntry cube_last_success() const { return m_cube_last_success; }
// --- guild ---
const GuildState &guild() const { return m_guild; }
std::vector<GuildMember> guild_members() const {
std::vector<GuildMember> v;
v.reserve(m_guild_members.size());
for (auto &kv : m_guild_members) {
v.push_back(kv.second);
}
return v;
}
const GuildGrade &guild_grade(int i) const {
static const GuildGrade kEmpty;
return (i >= 0 && i < 16) ? m_guild_grades[i] : kEmpty;
}
bool guild_dirty() { bool d = m_guild_dirty; m_guild_dirty = false; return d; }
// --- guild war / guild skill ---
const GuildSkillState &guild_skill() const { return m_guild_skill; }
bool guild_skill_dirty() { bool d = m_guild_skill_dirty; m_guild_skill_dirty = false; return d; }
const GuildWarStatus &guild_war() const { return m_guild_war; }
// active GvG pairs (src,dst); order not significant.
const std::vector<GuildWarPair> &guild_wars() const { return m_guild_wars; }
bool guild_war_dirty() { bool d = m_guild_war_dirty; m_guild_war_dirty = false; return d; }
std::string guild_name(uint32_t id) const {
auto it = m_guild_names.find(id);
return it == m_guild_names.end() ? std::string() : it->second;
}
// one-shot WAR state transitions (declare/accept/start/end) for toasts.
std::vector<GuildWarStatus> drain_guild_war_events() {
auto v = std::move(m_guild_war_events);
m_guild_war_events.clear();
return v;
}
std::vector<GuildWarScore> drain_guild_war_scores() {
auto v = std::move(m_guild_war_scores);
m_guild_war_scores.clear();
return v;
}
// --- refine ---
std::vector<RefineCue> drain_refine_cues() {
auto v = std::move(m_refine_cues);
m_refine_cues.clear();
return v;
}
std::vector<DragonSoulCue> drain_ds_cues() {
auto v = std::move(m_ds_cues);
m_ds_cues.clear();
return v;
}
const Item &dragon_soul_slot(int cell) const;
// --- P9 world systems ---
std::vector<WarpCue> drain_warps() {
auto v = std::move(m_warps);
m_warps.clear();
return v;
}
// server wall-clock (unix seconds) as of the last GC_TIME; add the elapsed
// real time yourself for a running clock.
int64_t server_time() const { return m_server_time; }
bool take_time_dirty() { bool d = m_time_dirty; m_time_dirty = false; return d; }
int channel() const { return m_channel; }
bool take_channel_dirty() { bool d = m_channel_dirty; m_channel_dirty = false; return d; }
const std::vector<NPCMark> &npc_marks() const { return m_npc_marks; }
bool take_npc_marks_dirty() { bool d = m_npc_marks_dirty; m_npc_marks_dirty = false; return d; }
std::vector<WorldMarker> markers() const {
std::vector<WorldMarker> v;
v.reserve(m_markers.size());
for (auto &kv : m_markers) {
v.push_back(kv.second);
}
return v;
}
bool take_markers_dirty() { bool d = m_markers_dirty; m_markers_dirty = false; return d; }
std::vector<uint32_t> drain_mount_changes() {
auto v = std::move(m_mount_changes);
m_mount_changes.clear();
return v;
}
// items: normal inventory, 24 wear positions, and the independent 4x4 belt inventory.
const Item &inv_slot(int cell) const;
const Item &equip_slot(int wear) const;
const Item &belt_slot(int cell) const;
const Item &item_slot(uint8_t window, int cell) const;
const ViewedEquipment *viewed_equipment(uint32_t vid) const;
const GroundItem *ground(uint32_t vid) const;
std::vector<uint32_t> ground_vids() const;
std::vector<InvChange> drain_inv() {
auto v = std::move(m_inv_changes);
m_inv_changes.clear();
return v;
}
std::vector<GroundChange> drain_ground() {
auto v = std::move(m_ground_changes);
m_ground_changes.clear();
return v;
}
std::vector<uint32_t> drain_view_equipment_changes() {
auto v = std::move(m_view_equipment_changes);
m_view_equipment_changes.clear();
return v;
}
std::vector<ItemEvent> drain_item_events() {
auto v = std::move(m_item_events);
m_item_events.clear();
return v;
}
std::vector<PvpRelation> drain_pvp_changes() {
auto v = std::move(m_pvp_changes);
m_pvp_changes.clear();
return v;
}
std::vector<PvpRelation> pvp_relations() const;
bool take_duel_started() {
bool started = m_duel_started;
m_duel_started = false;
return started;
}
const LoverInfo &lover() const { return m_lover; }
bool take_lover_dirty() {
bool dirty = m_lover_dirty;
m_lover_dirty = false;
return dirty;
}
std::vector<Change> drain_changes() {
auto v = std::move(m_changes);
m_changes.clear();
return v;
}
std::vector<ChatMsg> drain_chat() {
auto v = std::move(m_chat);
m_chat.clear();
return v;
}
// vids whose hp/sp/level/dead/stunned changed since last drain (deduped).
std::vector<uint32_t> drain_vitals() {
auto v = std::move(m_vitals);
m_vitals.clear();
return v;
}
std::vector<DamageEvent> drain_damage() {
auto v = std::move(m_damage);
m_damage.clear();
return v;
}
std::vector<MotionEvent> drain_motions() {
auto v = std::move(m_motions);
m_motions.clear();
return v;
}
std::vector<DigMotionEvent> drain_dig_motions() {
auto v = std::move(m_dig_motions);
m_dig_motions.clear();
return v;
}
std::vector<FishingEvent> drain_fishing_events() {
auto v = std::move(m_fishing_events);
m_fishing_events.clear();
return v;
}
std::vector<DungeonEvent> drain_dungeon_events() {
auto v = std::move(m_dungeon_events);
m_dungeon_events.clear();
return v;
}
const std::vector<LandArea> &land_areas() const { return m_land_areas; }
bool take_land_dirty() { bool d = m_land_dirty; m_land_dirty = false; return d; }
std::vector<Observer> observers() const {
std::vector<Observer> v;
v.reserve(m_observers.size());
for (auto &kv : m_observers) v.push_back(kv.second);
return v;
}
std::vector<ObserverEvent> drain_observer_events() {
auto v = std::move(m_observer_events);
m_observer_events.clear();
return v;
}
std::vector<AffectChange> drain_affects() {
auto v = std::move(m_affect_changes);
m_affect_changes.clear();
return v;
}
std::vector<EffectCue> drain_effect_cues() {
auto v = std::move(m_effect_cues);
m_effect_cues.clear();
return v;
}
std::vector<Affect> affects() const {
std::vector<Affect> v;
v.reserve(m_affects.size());
for (auto &kv : m_affects) {
v.push_back(kv.second);
}
return v;
}
// true once since GC_PLAYER_POINTS last arrived (consumes the flag).
bool take_points_dirty() {
bool d = m_points_dirty;
m_points_dirty = false;
return d;
}
// true once since GC_TARGET_INFO last arrived (consumes the flag).
bool take_target_dirty() {
bool d = m_target_dirty;
m_target_dirty = false;
return d;
}
private:
Entity &touch(uint32_t vid, bool &created);
void start_move(Entity &e, float tx, float ty, uint32_t start_ms, uint32_t dur_ms, uint8_t func);
void mark_vitals(uint32_t vid);
Item *mut_slot(uint8_t window, uint16_t cell);
std::unordered_map<uint32_t, Entity> m_ents;
uint32_t m_main_vid = 0;
uint32_t m_now = 0;
std::vector<Change> m_changes;
std::vector<ChatMsg> m_chat;
std::vector<uint32_t> m_vitals;
std::vector<DamageEvent> m_damage;
std::vector<MotionEvent> m_motions;
std::vector<DigMotionEvent> m_dig_motions;
std::vector<FishingEvent> m_fishing_events;
std::vector<DungeonEvent> m_dungeon_events;
std::vector<LandArea> m_land_areas;
bool m_land_dirty = false;
std::unordered_map<uint32_t, Observer> m_observers;
std::vector<ObserverEvent> m_observer_events;
std::unordered_map<uint32_t, Affect> m_affects;
std::vector<AffectChange> m_affect_changes;
std::vector<EffectCue> m_effect_cues;
std::vector<FlyCue> m_fly_cues;
std::vector<FlyTargetCue> m_fly_target_cues;
uint8_t m_skills[SKILL_MAX_NUM] = {0};
uint8_t m_skill_master[SKILL_MAX_NUM] = {0};
uint8_t m_skill_group = 0;
bool m_skill_group_dirty = false;
bool m_skills_dirty = false;
QuickSlot m_quickslots[QUICKSLOT_MAX_NUM] = {};
bool m_quickslots_dirty = false;
std::vector<int> m_cooldown_ends;
std::vector<ScriptCue> m_scripts;
std::vector<ConfirmCue> m_confirms;
std::unordered_map<uint16_t, QuestInfo> m_quests;
std::vector<uint16_t> m_quest_changes;
PlayerPoints m_points;
bool m_points_dirty = false;
uint32_t m_target_vid = 0;
uint8_t m_target_hp_pct = 0;
bool m_target_dirty = false;
Item m_inventory[INVENTORY_MAX_NUM];
Item m_equipment[WEAR_MAX_NUM];
Item m_belt[BELT_INVENTORY_MAX_NUM];
std::unordered_map<uint32_t, ViewedEquipment> m_view_equipment;
std::unordered_map<uint32_t, GroundItem> m_ground;
std::vector<InvChange> m_inv_changes;
std::vector<GroundChange> m_ground_changes;
std::vector<uint32_t> m_view_equipment_changes;
std::vector<ItemEvent> m_item_events;
std::unordered_map<uint64_t, PvpRelation> m_pvp;
std::vector<PvpRelation> m_pvp_changes;
bool m_duel_started = false;
LoverInfo m_lover;
bool m_lover_dirty = false;
// P8 social / shop / storage
std::unordered_map<uint32_t, PartyMember> m_party;
uint8_t m_party_mode = 0;
bool m_party_dirty = false;
std::vector<uint32_t> m_party_invites;
std::unordered_map<std::string, Friend> m_friends;
bool m_friends_dirty = false;
bool m_shop_open = false;
uint32_t m_shop_vid = 0;
std::vector<ShopEntry> m_shop_items; // == m_shop_tabs[0].items when tabs present
std::vector<ShopTab> m_shop_tabs;
bool m_shop_dirty = false;
std::vector<std::string> m_shop_errors;
ExchangeState m_exchange;
bool m_exchange_dirty = false;
Item m_safebox[SAFEBOX_MAX_NUM];
bool m_safebox_open = false;
int m_safebox_size = 0;
int64_t m_safebox_gold = 0;
bool m_safebox_dirty = false;
Item m_mall[MALL_MAX_NUM];
bool m_mall_open = false;
int m_mall_size = 0;
bool m_mall_dirty = false;
CubeState m_cube;
std::vector<CubeEvent> m_cube_events;
CubeResultEntry m_cube_last_success;
void apply_server_command(const std::string &line);
// P9 world systems
std::vector<WarpCue> m_warps;
int64_t m_server_time = 0;
bool m_time_dirty = false;
int m_channel = 0;
bool m_channel_dirty = false;
std::vector<NPCMark> m_npc_marks;
bool m_npc_marks_dirty = false;
std::unordered_map<int32_t, WorldMarker> m_markers;
bool m_markers_dirty = false;
std::vector<uint32_t> m_mount_changes;
// guild / refine
GuildState m_guild;
std::unordered_map<uint32_t, GuildMember> m_guild_members;
GuildGrade m_guild_grades[16];
bool m_guild_dirty = false;
GuildSkillState m_guild_skill;
bool m_guild_skill_dirty = false;
GuildWarStatus m_guild_war;
std::vector<GuildWarPair> m_guild_wars;
bool m_guild_war_dirty = false;
std::unordered_map<uint32_t, std::string> m_guild_names;
std::vector<GuildWarStatus> m_guild_war_events;
std::vector<GuildWarScore> m_guild_war_scores;
std::vector<RefineCue> m_refine_cues;
// dragon-soul refine
Item m_dragon_soul[DRAGON_SOUL_MAX_NUM];
std::vector<DragonSoulCue> m_ds_cues;
};
} // namespace mtnet
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+318
View File
@@ -0,0 +1,318 @@
#pragma once
// M2Client — GDExtension Node wrapping the Metin2 net client. GDScript drives it:
//
// var c = M2Client.new()
// add_child(c)
// c.phase_changed.connect(func(p): print("phase ", p))
// c.char_list.connect(func(list): c.select_character(list[0]["index"]))
// c.entered_game.connect(func(): print("in game"))
// c.connect_to_server("192.168.21.203", 11000, "192.168.21.203", 11011,
// "admin", "123456789")
//
// Orchestration: AuthClient(auth_host:auth_port) -> on auth_ok, connect
// GameClient(game_host:game_port) which does its own KX handshake, sends
// CG_LOGIN2, surfaces the character list, then select_character() drives to the
// game phase. _process() pumps whichever stream is active.
#include <godot_cpp/classes/image.hpp>
#include <godot_cpp/classes/node.hpp>
#include <godot_cpp/classes/ref.hpp>
#include <godot_cpp/variant/array.hpp>
#include <godot_cpp/variant/dictionary.hpp>
#include <godot_cpp/variant/packed_byte_array.hpp>
#include <godot_cpp/variant/string.hpp>
#include <memory>
#include <unordered_set>
#include <vector>
namespace mtnet {
class AuthClient;
class GameClient;
class MarkClient;
class MarkImageSet;
} // namespace mtnet
namespace mtgodot {
class M2Client : public godot::Node {
GDCLASS(M2Client, godot::Node)
public:
M2Client();
~M2Client() override;
void _process(double delta) override;
void _notification(int what);
// host/port for auth and game servers; account credentials.
void connect_to_server(const godot::String &auth_host, int auth_port,
const godot::String &game_host, int game_port, const godot::String &id,
const godot::String &pw);
void disconnect_from_server();
// pick a slot from the char_list payload (its "index" field).
bool select_character(int index);
// CG_CHARACTER_CREATE / CG_CHARACTER_DELETE (mirror SendCreate/DestroyCharacterPacket).
bool create_character(int slot, const godot::String &name, int job, int shape,
int con, int intel, int str, int dex);
bool delete_character(int slot, const godot::String &private_code);
// Rename a character slot with a server-side rename card.
bool change_name(int slot, const godot::String &name);
// GC_EMPIRE (0 = server still wants an empire pick before select).
int get_empire() const;
// 3 or 4 depending on which GC_LOGIN_SUCCESS the server sent.
int get_slot_count() const;
// --- in-game intents (mirror CPythonNetworkStream) ---
// rot_deg is a compass heading in degrees; wire form is rot_deg/5 like the
// original client. x/y are server cm. time is filled from the frame clock.
bool move(int func, int arg, double rot_deg, int x, int y);
// Sends CG_CHARACTER_POSITION (legacy posture/position enum, 0..255).
bool character_position(int position);
// Sends a CG_SYNC_POSITION batch. Each entry is {vid, x, y} in server cm;
// the legacy packet allows at most 16 entries.
bool sync_positions(const godot::Array &positions);
bool request_warp();
bool fishing(double rot_deg);
bool request_dungeon();
bool attack(int motion, int victim_vid);
bool set_target(int victim_vid);
bool say(int type, const godot::String &text);
bool whisper(const godot::String &to, const godot::String &text);
// cast: CG_MOVE with func = FUNC_SKILL(0x80) | (motion_idx & 0x7F).
bool cast_skill(int motion_idx, double rot_deg, int x, int y);
// Real skill intent. For a tracked target, sends CG_FLY_TARGETING before
// CG_USE_SKILL, matching the legacy client packet order.
bool use_skill(int skill_id, int target_vid);
// Ranged animation events and area-target selection use separate packets.
bool shoot(int skill_id);
bool add_fly_targeting(int target_vid, int x, int y);
// server-driven quest command: "/skillup <skill_id>".
bool skill_up(int skill_id);
int get_skill_group() const;
godot::Array get_skills() const; // [{id, level, master}] for skills the player has
godot::Array get_quickslots() const; // [{pos, type, ref}] restored quickslots
bool quickslot_add(int pos, int type, int ref);
bool quickslot_del(int pos);
bool quickslot_swap(int pos, int change_pos);
// quest / NPC
bool click_npc(int vid);
bool script_answer(int answer); // dialog choice (0..N-1) or 255 = continue
bool script_button(int idx); // quest-log button
bool script_select_item(int selection); // inventory cell/item position
bool quest_input(const godot::String &text);
bool quest_confirm(bool yes, int request_pid);
bool quest_cancel();
godot::Array get_quests() const; // [{index, title, counter_name, counter_value, ...}]
// --- P8 party ---
bool party_invite(int vid);
bool party_answer(int leader_pid, bool accept);
bool party_leave(int pid); // expel <pid>, or your own pid to leave
bool party_use_skill(int skill_index, int target_vid);
bool party_set_distribute(int mode);
bool party_set_state(int pid, int role, bool on); // CG_PARTY_SET_STATE (role = PARTY_ROLE_*)
godot::Array get_party() const; // [{pid, vid, name, leader, hp_pct, state, affects[7]}]
int get_party_distribute_mode() const;
// --- P8 messenger / friends ---
bool add_friend(const godot::String &name);
bool remove_friend(const godot::String &name);
godot::Array get_friends() const; // [{name, online}]
godot::Dictionary get_lover() const; // {valid, name, love_point}
// --- P8 NPC shop ---
bool shop_buy(int pos, int count);
bool shop_sell(int inv_cell, int count);
bool shop_close();
bool is_shop_open() const;
godot::Array get_shop_items() const; // [{pos, vnum, price, count}] — tab 0
godot::Dictionary get_shop() const; // {vid, open, tabs:[{name, coin_type, items:[...]}]}
// --- P8 exchange / trade ---
bool exchange_start(int vid);
bool exchange_add_item(int inv_window, int inv_cell, int display_pos);
bool exchange_add_gold(int gold);
bool exchange_accept();
bool exchange_cancel();
godot::Dictionary get_exchange() const; // {active, partner_vid, self_items, peer_items, ...}
// --- P8 safebox / storage ---
bool safebox_checkin(int safe_pos, int inv_window, int inv_cell);
bool safebox_checkout(int safe_pos, int inv_window, int inv_cell);
bool safebox_move(int from_cell, int to_cell, int count);
bool is_safebox_open() const;
int get_safebox_size() const;
int get_safebox_gold() const;
godot::Array get_safebox_items() const; // [{cell, vnum, count}]
// --- item-mall (창고몰) ---
bool is_mall_open() const;
int get_mall_size() const;
godot::Array get_mall_items() const; // [{cell, vnum, count}]
bool mall_checkout(int mall_pos, int inv_window, int inv_cell);
// --- private (PC) shop ---
// items: Array of {vnum, count, inv_cell, price, display_pos}
bool open_private_shop(const godot::String &sign, const godot::Array &items);
bool close_private_shop();
// --- cube (제작) ---
godot::Dictionary get_cube() const; // {open, npc_vnum, need_gold, recipes:[...]}
bool cube_make(int result_index);
bool cube_request_result_list(int npc_vnum);
bool cube_request_materials(int start_index, int count);
// --- guild ---
godot::Dictionary get_guild() const; // {in_guild, id, name, level, exp, gold, ...}
godot::Array get_guild_members() const; // [{pid, name, grade, job, level, offer, general}]
godot::Array get_guild_grades() const; // [{name, auth}] index = grade
bool guild_add_member(int vid);
bool guild_remove_member(int pid);
bool guild_offer(int amount);
bool guild_answer_make(const godot::String &name);
// --- guild war / guild skill ---
godot::Dictionary get_guild_skill() const; // {valid, skill_point, guild_point, max_guild_point, levels[12]}
godot::Array get_guild_wars() const; // [{src, dst, src_name, dst_name}] active GvG
godot::Dictionary get_guild_war() const; // {opp_guild_id, opp_name, type, state}
godot::String get_guild_name(int guild_id) const;
bool use_guild_skill(int skill_vnum, int target_vid);
bool declare_guild_war(const godot::String &guild_name); // sends "/war <name>"
// --- guild marks (会徽) ---
// Opens the side connection to `host:port` and pulls the mark images using
// the handle/random_key from login. `guild_marks_ready` fires when done.
// A port of 0 skips the query. Safe to call again on `guild_mark_updated`.
bool download_guild_marks(const godot::String &host, int port);
// Download one raw guild-symbol file via the mark side connection.
bool download_guild_symbol(const godot::String &host, int port, int guild_id);
// Raw bytes from the last completed download_guild_symbol call.
godot::PackedByteArray get_guild_symbol() const;
bool are_guild_marks_ready() const;
// {host, port} last used for a mark connection (0 port = none configured yet).
godot::Dictionary get_mark_server() const;
// Upload this guild's 16x12 mark (a godot::Image, converted/resized as needed).
// `guild_mark_uploaded(ok)` fires when the packet has left the socket.
bool upload_guild_mark(const godot::String &host, int port, int guild_id,
const godot::Ref<godot::Image> &img);
// Upload the raw bytes of a guild-symbol image file (server validates 64x128).
bool upload_guild_symbol(const godot::String &host, int port, int guild_id,
const godot::PackedByteArray &file_bytes);
// {found, img_idx, x, y, w, h} — position of a guild's 16x12 mark in its image.
godot::Dictionary get_guild_mark(int guild_id) const;
// A 16x12 RGBA8 godot::Image for a guild's mark, or an empty (null) Ref if we
// have no mark for it yet.
godot::Ref<godot::Image> get_guild_mark_image(int guild_id) const;
// --- refine / upgrade ---
bool refine(int pos, int type); // confirm refine of the item at inventory `pos`
// --- dragon soul refine ---
// mode: 0 = 升级 (upgrade), 1 = 改良 (improvement), 2 = 精炼 (refine).
// cells: inventory cells; cells[0] = the dragon soul, cells[1..] = materials (<=15 total).
bool ds_refine(int mode, const godot::Array &cells);
godot::Array get_dragon_souls() const; // [{cell, vnum, count}] non-empty DS-window slots
// --- P9 world systems ---
int get_channel() const;
int64_t get_server_time() const; // unix seconds as of last GC_TIME
godot::Array get_npc_marks() const; // [{type, vnum, name, pos}] pos = Godot metres
godot::Array get_land_areas() const; // [{id, guild_id, rect}] in server cm
godot::Array get_observers() const; // [{vid, pos}] in Godot metres
godot::Array get_world_markers() const; // [{id, name, vid, type, pos}]
// items. window: 1=inventory, 2=equipment (mtnet::WINDOW_*).
bool move_item(int from_window, int from_cell, int to_window, int to_cell, int count);
bool use_item(int window, int cell);
bool drop_item(int window, int cell, int gold);
bool drop_item_count(int window, int cell, int gold, int count);
bool use_item_to_item(int source_window, int source_cell, int target_window, int target_cell);
bool give_item(int target_vid, int window, int cell, int count);
bool pickup_item(int ground_vid);
godot::Array get_inventory() const; // non-empty slots
godot::Array get_equipment() const; // WEAR_MAX_NUM slots (may be empty)
godot::Array get_belt_inventory() const; // non-empty 4x4 belt slots
godot::Array get_view_equipment(int vid) const; // 11 legacy inspect slots (may be empty)
godot::Dictionary get_item(int window, int cell) const;
godot::Array get_ground_items() const; // {vid, vnum, pos}
godot::Array get_pvp_relations() const; // [{src_vid, dst_vid, mode}]
// --- networked world snapshot (positions already Godot-space, metres) ---
godot::Dictionary get_entity(int vid) const;
godot::Array get_entities() const;
int get_main_vid() const;
godot::Dictionary get_points() const; // local player stat block
godot::Dictionary get_target() const; // {vid, hp_percent} of selected target
godot::Array get_affects() const; // active buffs/debuffs on the local player
// App lifecycle (F5). suspend() stops pumping the socket (called
// automatically on NOTIFICATION_APPLICATION_PAUSED); resume() re-enables it.
// A mobile OS tears the TCP connection down within seconds of backgrounding,
// so the first pump after resume typically surfaces `disconnected` — call
// reconnect() to redo the login with the stored credentials.
void suspend();
void resume();
bool reconnect();
bool is_suspended() const { return suspended; }
godot::String get_stage() const { return stage_name(); }
bool is_in_game() const;
// Drain the socket + answer PING once, WITHOUT emitting the per-frame world
// signals. Call this from GDScript around any blocking work (model loads,
// map build) so the server doesn't drop us for going silent.
void net_poll();
protected:
static void _bind_methods();
private:
enum class Stage { Idle, AuthConnect, AuthWait, GameConnect, GameLogin, InGame, Failed };
Stage stage = Stage::Idle;
godot::String stage_name() const;
void set_stage(Stage s);
godot::String game_host;
int game_port = 0;
godot::String account_id;
// stored so reconnect() can redo the full auth->game flow after a resume.
godot::String cfg_auth_host, cfg_game_host, cfg_id, cfg_pw;
int cfg_auth_port = 0, cfg_game_port = 0;
bool have_cfg = false;
bool suspended = false;
std::unique_ptr<mtnet::AuthClient> auth;
std::unique_ptr<mtnet::GameClient> game;
std::unique_ptr<mtnet::MarkClient> mark;
std::unique_ptr<mtnet::MarkImageSet> mark_store; // survives after `mark` is torn down
godot::String mark_host;
int mark_port = 0;
bool mark_ready = false;
uint32_t symbol_guild_id = 0;
std::vector<uint8_t> symbol_data;
void pump_mark();
int last_auth_state = -1;
int last_game_state = -1;
int last_game_phase = -1;
bool char_list_emitted = false;
godot::Array build_char_list() const;
// CG_ENTERGAME is sent ~1.5s into PHASE_LOADING (not immediately) — sending
// it before the server finishes the spawn burst makes it drop us ~10s later.
uint64_t loading_since_ms = 0;
bool enter_game_sent = false;
bool shop_open_seen = false; // last shop_open() state we emitted a signal for
bool mall_open_seen = false;
uint32_t last_login_key = 0; // for a cross-server GC_WARP reconnect
void warp_to_game_server(const godot::String &host, int port);
std::unordered_set<uint32_t> dead_seen; // vids we've already fired entity_dead for
void pump_auth();
void pump_game();
};
} // namespace mtgodot
+266
View File
@@ -0,0 +1,266 @@
#pragma once
// MarkClient — the Metin2 guild-mark side connection on top of NetStream.
//
// Download (default): connect -> (base KX) -> on_cipher_active: CG_MARK_LOGIN
// -> GC_PHASE(PHASE_LOGIN): CG_MARK_IDXLIST -> GC_MARK_IDXLIST (guild->mark_id)
// -> per referenced image: CG_MARK_CRCLIST -> GC_MARK_BLOCK (LZO 64x48 blocks
// into MarkImageSet) -> complete().
//
// Upload (set_upload_mark / set_upload_symbol before connect): same login, then
// on PHASE_LOGIN push CG_MARK_UPLOAD (raw 16x12 mark) or CG_GUILD_SYMBOL_UPLOAD
// (head + raw file bytes) and finish. upload_done() once the send buffer drains.
//
// handle / random_key come from GC_LOGIN_SUCCESS3/4 on the game connection.
// GC_MARK_* bodies are framed by a u32 buf_size (whole-packet size), so they go
// through NetStream::on_raw() rather than the u16-length on_packet() path.
#include "mark_image.h"
#include "net_stream.h"
#include <cstring>
#include <vector>
namespace mtnet {
class MarkClient : public NetStream {
public:
enum class Mode { Download, DownloadSymbol, UploadMark, UploadSymbol };
MarkClient(uint32_t handle, uint32_t random_key)
: m_handle(handle), m_random_key(random_key) {}
// Call before connect() to switch this connection to an upload.
void set_upload_mark(uint32_t guild_id, const uint32_t px[GUILD_MARK_WIDTH * GUILD_MARK_HEIGHT]) {
m_mode = Mode::UploadMark;
m_up_gid = guild_id;
std::memcpy(m_up_mark, px, sizeof(m_up_mark));
}
void set_upload_symbol(uint32_t guild_id, std::vector<uint8_t> bytes) {
m_mode = Mode::UploadSymbol;
m_up_gid = guild_id;
m_up_symbol = std::move(bytes);
}
void set_download_symbol(uint32_t guild_id, uint32_t crc = 0, uint32_t size = 0) {
m_mode = Mode::DownloadSymbol;
m_symbol_gid = guild_id;
m_symbol_crc = crc;
m_symbol_size = size;
}
Mode mode() const { return m_mode; }
bool complete() const { return m_complete; }
bool logged_in() const { return m_login_sent; }
bool upload_sent() const { return m_upload_sent; }
// upload finished = the packet was queued AND has left our send buffer.
bool upload_done() const { return m_upload_sent && send_pending() == 0; }
const MarkImageSet &marks() const { return m_marks; }
uint32_t symbol_guild_id() const { return m_symbol_gid; }
const std::vector<uint8_t> &symbol_data() const { return m_symbol_data; }
MarkImageSet &marks() { return m_marks; }
size_t images_done() const { return m_next; }
size_t images_wanted() const { return m_needed.size(); }
protected:
void on_cipher_active() override {
CGMarkLogin p{};
p.header = CG_MARK_LOGIN;
p.length = sizeof(p);
p.handle = m_handle;
p.random_key = m_random_key;
send_packet(&p, sizeof(p));
m_login_sent = true;
}
void on_phase(uint8_t phase) override {
if (phase == PHASE_CLOSE) {
m_complete = true;
return;
}
if (phase != PHASE_LOGIN) {
return;
}
if (m_mode == Mode::UploadMark) {
send_upload_mark();
m_upload_sent = true;
m_complete = true;
return;
}
if (m_mode == Mode::UploadSymbol) {
send_upload_symbol();
m_upload_sent = true;
m_complete = true;
return;
}
if (m_mode == Mode::DownloadSymbol) {
CGSymbolCRC p{CG_SYMBOL_CRC, sizeof(CGSymbolCRC), m_symbol_gid, m_symbol_crc, m_symbol_size};
send_packet(&p, sizeof(p));
m_symbol_crc_sent = true;
return;
}
if (!m_idx_requested) {
CGMarkIDXList p{CG_MARK_IDXLIST, sizeof(CGMarkIDXList)};
send_packet(&p, sizeof(p));
m_idx_requested = true;
}
}
Raw on_raw(uint16_t header) override {
if (header == GC_MARK_IDXLIST) {
return recv_idxlist();
}
if (header == GC_MARK_BLOCK) {
return recv_block();
}
return Raw::NotHandled;
}
// GC_SYMBOL_DATA is a normal u16-length packet whose body is the raw symbol file.
// Other small packets on this stream (incl. GC_MARK_DIFF_DATA) are discarded.
bool on_packet(uint16_t header, uint16_t len) override {
if (header == GC_SYMBOL_DATA && m_mode == Mode::DownloadSymbol) {
if (len < sizeof(GCSymbolData)) {
consume_packet(len);
return true;
}
std::vector<uint8_t> buf(len);
if (!recv_bytes(buf.data(), buf.size())) {
return false;
}
GCSymbolData p{};
std::memcpy(&p, buf.data(), sizeof(p));
m_symbol_gid = p.guild_id;
m_symbol_data.assign(buf.begin() + sizeof(p), buf.end());
m_complete = true;
return true;
}
if (len <= 4096) {
uint8_t discard[4096];
recv_bytes(discard, len);
} else {
drop_recv();
}
return true;
}
void on_disconnect() override { m_complete = true; }
private:
static constexpr uint32_t kMaxBody = 8u * 1024 * 1024;
Raw recv_idxlist() {
if (recv_avail() < sizeof(GCMarkIDXList)) {
return Raw::NeedMore;
}
GCMarkIDXList head;
std::memcpy(&head, recv_ptr(), sizeof(head));
uint32_t total = head.buf_size;
if (total < sizeof(GCMarkIDXList)) {
total = (uint32_t)sizeof(GCMarkIDXList) + (uint32_t)head.count * 4;
}
if (total > kMaxBody) {
return Raw::Error;
}
if (recv_avail() < total) {
return Raw::NeedMore;
}
std::vector<uint8_t> buf(total);
recv_bytes(buf.data(), total);
parse_mark_idxlist(buf.data() + sizeof(GCMarkIDXList), total - sizeof(GCMarkIDXList),
head.count, m_marks);
m_needed = m_marks.needed_images();
m_next = 0;
if (m_needed.empty()) {
m_complete = true;
} else {
send_crclist(m_needed[0]);
}
return Raw::Consumed;
}
Raw recv_block() {
if (recv_avail() < sizeof(GCMarkBlock)) {
return Raw::NeedMore;
}
GCMarkBlock head;
std::memcpy(&head, recv_ptr(), sizeof(head));
uint32_t total = head.buf_size;
if (total < sizeof(GCMarkBlock) || total > kMaxBody) {
return Raw::Error;
}
if (recv_avail() < total) {
return Raw::NeedMore;
}
std::vector<uint8_t> buf(total);
recv_bytes(buf.data(), total);
parse_mark_block(buf.data() + sizeof(GCMarkBlock), total - sizeof(GCMarkBlock),
head.img_idx, head.count, m_marks);
++m_next;
if (m_next < m_needed.size()) {
send_crclist(m_needed[m_next]);
} else {
m_complete = true;
}
return Raw::Consumed;
}
void send_crclist(int img_idx) {
CGMarkCRCList p{};
p.header = CG_MARK_CRCLIST;
p.length = sizeof(p);
p.img_idx = (uint8_t)img_idx;
// crclist stays all-zero: "I have nothing for this image, send it whole".
send_packet(&p, sizeof(p));
}
void send_upload_mark() {
CGMarkUpload p{};
p.header = CG_MARK_UPLOAD;
p.length = sizeof(p);
p.gid = m_up_gid;
std::memcpy(p.image, m_up_mark, sizeof(p.image));
send_packet(&p, sizeof(p));
}
void send_upload_symbol() {
if (m_up_symbol.empty()) {
return;
}
std::vector<uint8_t> buf(sizeof(CGSymbolUpload) + m_up_symbol.size());
CGSymbolUpload head{};
head.header = CG_GUILD_SYMBOL_UPLOAD;
head.length = (uint16_t)buf.size();
head.handle = m_up_gid; // reference stores the guild id here
std::memcpy(buf.data(), &head, sizeof(head));
std::memcpy(buf.data() + sizeof(head), m_up_symbol.data(), m_up_symbol.size());
send_packet(buf.data(), buf.size());
}
void consume_packet(uint16_t len) {
std::vector<uint8_t> discard(len);
recv_bytes(discard.data(), discard.size());
}
uint32_t m_handle;
uint32_t m_random_key;
bool m_login_sent = false;
bool m_idx_requested = false;
bool m_complete = false;
Mode m_mode = Mode::Download;
uint32_t m_up_gid = 0;
uint32_t m_up_mark[GUILD_MARK_WIDTH * GUILD_MARK_HEIGHT] = {};
std::vector<uint8_t> m_up_symbol;
bool m_upload_sent = false;
uint32_t m_symbol_gid = 0;
uint32_t m_symbol_crc = 0;
uint32_t m_symbol_size = 0;
bool m_symbol_crc_sent = false;
std::vector<uint8_t> m_symbol_data;
MarkImageSet m_marks;
std::vector<int> m_needed;
size_t m_next = 0;
};
} // namespace mtnet
+174
View File
@@ -0,0 +1,174 @@
#include "mark_image.h"
#include <lzo/lzo1x.h>
#include <algorithm>
#include <cstring>
namespace mtnet {
namespace {
bool g_lzo_ready = false;
void ensure_lzo() {
if (!g_lzo_ready) {
lzo_init();
g_lzo_ready = true;
}
}
uint16_t rd_u16(const uint8_t *p) {
return (uint16_t)(p[0] | (p[1] << 8));
}
uint32_t rd_u32(const uint8_t *p) {
return (uint32_t)p[0] | ((uint32_t)p[1] << 8) | ((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24);
}
} // namespace
void MarkImageSet::clear() {
m_gid_mark.clear();
m_images.clear();
}
void MarkImageSet::add_mark(uint32_t guild_id, uint32_t mark_id) {
if (mark_id >= (uint32_t)(MARK_IMAGE_MAX_COUNT * MARK_PER_IMAGE)) {
return;
}
m_gid_mark[guild_id] = mark_id;
}
uint32_t MarkImageSet::mark_id(uint32_t guild_id) const {
auto it = m_gid_mark.find(guild_id);
return it == m_gid_mark.end() ? 0xFFFFFFFFu : it->second;
}
std::vector<int> MarkImageSet::needed_images() const {
std::vector<int> v;
for (const auto &kv : m_gid_mark) {
int idx = (int)(kv.second / MARK_PER_IMAGE);
bool seen = false;
for (int e : v) {
if (e == idx) {
seen = true;
break;
}
}
if (!seen) {
v.push_back(idx);
}
}
std::sort(v.begin(), v.end());
return v;
}
std::vector<uint32_t> &MarkImageSet::touch_image(int img_idx) {
auto it = m_images.find(img_idx);
if (it == m_images.end()) {
it = m_images.emplace(img_idx, std::vector<uint32_t>(MARK_IMAGE_PIXELS, 0)).first;
}
return it->second;
}
bool MarkImageSet::apply_block(int img_idx, int block_pos, const uint8_t *comp, uint32_t comp_len) {
if (img_idx < 0 || img_idx >= MARK_IMAGE_MAX_COUNT) {
return false;
}
if (block_pos < 0 || block_pos >= MARK_BLOCK_TOTAL_COUNT || comp == nullptr || comp_len == 0) {
return false;
}
ensure_lzo();
uint32_t block[MARK_BLOCK_PIXELS];
lzo_uint out_len = sizeof(block);
int r = lzo1x_decompress_safe(comp, comp_len, (uint8_t *)block, &out_len, nullptr);
if (r != LZO_E_OK || out_len != sizeof(block)) {
return false;
}
const int row_block = block_pos / MARK_BLOCK_COL_COUNT;
const int col_block = block_pos % MARK_BLOCK_COL_COUNT;
const int ox = col_block * MARK_BLOCK_WIDTH;
const int oy = row_block * MARK_BLOCK_HEIGHT;
std::vector<uint32_t> &img = touch_image(img_idx);
for (int j = 0; j < MARK_BLOCK_HEIGHT; ++j) {
uint32_t *dst = img.data() + (size_t)(oy + j) * MARK_IMAGE_WIDTH + ox;
const uint32_t *src = block + (size_t)j * MARK_BLOCK_WIDTH;
std::memcpy(dst, src, MARK_BLOCK_WIDTH * sizeof(uint32_t));
}
return true;
}
MarkRect MarkImageSet::rect_of(uint32_t guild_id) const {
MarkRect rc;
auto it = m_gid_mark.find(guild_id);
if (it == m_gid_mark.end()) {
return rc;
}
const uint32_t mid = it->second;
const int pos = (int)(mid % MARK_PER_IMAGE);
rc.found = true;
rc.img_idx = (int)(mid / MARK_PER_IMAGE);
rc.x = (pos % MARK_COL_COUNT) * GUILD_MARK_WIDTH;
rc.y = (pos / MARK_COL_COUNT) * GUILD_MARK_HEIGHT;
return rc;
}
const std::vector<uint32_t> &MarkImageSet::image(int img_idx) const {
static const std::vector<uint32_t> kEmpty;
auto it = m_images.find(img_idx);
return it == m_images.end() ? kEmpty : it->second;
}
std::vector<uint32_t> MarkImageSet::mark_pixels(uint32_t guild_id) const {
MarkRect rc = rect_of(guild_id);
if (!rc.found) {
return {};
}
const std::vector<uint32_t> &img = image(rc.img_idx);
if (img.empty()) {
return {};
}
std::vector<uint32_t> out((size_t)GUILD_MARK_WIDTH * GUILD_MARK_HEIGHT, 0);
for (int j = 0; j < GUILD_MARK_HEIGHT; ++j) {
const uint32_t *src = img.data() + (size_t)(rc.y + j) * MARK_IMAGE_WIDTH + rc.x;
std::memcpy(out.data() + (size_t)j * GUILD_MARK_WIDTH, src, GUILD_MARK_WIDTH * sizeof(uint32_t));
}
return out;
}
size_t parse_mark_idxlist(const uint8_t *body, size_t n, uint16_t count, MarkImageSet &out) {
size_t done = 0;
for (uint16_t i = 0; i < count; ++i) {
const size_t off = (size_t)i * 4;
if (off + 4 > n) {
break;
}
uint16_t gid = rd_u16(body + off);
uint16_t mid = rd_u16(body + off + 2);
out.add_mark(gid, mid);
++done;
}
return done;
}
size_t parse_mark_block(const uint8_t *body, size_t n, int img_idx, uint32_t count, MarkImageSet &out) {
size_t off = 0;
size_t applied = 0;
for (uint32_t i = 0; i < count; ++i) {
if (off + 5 > n) {
break;
}
uint8_t pos = body[off];
uint32_t comp_size = rd_u32(body + off + 1);
off += 5;
if (comp_size == 0 || off + comp_size > n) {
break;
}
if (out.apply_block(img_idx, pos, body + off, comp_size)) {
++applied;
}
off += comp_size;
}
return applied;
}
} // namespace mtnet
+77
View File
@@ -0,0 +1,77 @@
#pragma once
// MarkImageSet — the client side of the Metin2 guild-mark image store.
//
// A guild mark is a 16x12 RGBA sprite. The server keeps them packed into up to
// five 512x512 "mark images"; each image is an 8x10 grid of 64x48 blocks and
// each block is a 4x4 grid of marks. The mark server streams the blocks that
// differ from what we hold (LZO1X-compressed, 12288 bytes raw per block).
//
// Wire: GC_MARK_IDXLIST gives guild_id -> mark_id; mark_id / 1280 picks the
// image, mark_id % 1280 the position within it. GC_MARK_BLOCK carries the
// compressed pixels. See wire.h for the packet layout and MarkImage.h in the
// reference client for the geometry.
#include "wire.h"
#include <cstdint>
#include <map>
#include <vector>
namespace mtnet {
struct MarkRect {
bool found = false;
int img_idx = 0;
int x = 0, y = 0; // top-left in the 512x512 image
int w = GUILD_MARK_WIDTH;
int h = GUILD_MARK_HEIGHT;
};
class MarkImageSet {
public:
// GC_MARK_IDXLIST entry.
void add_mark(uint32_t guild_id, uint32_t mark_id);
// Which images at least one known guild references, ascending, deduplicated.
// This is the set of CG_MARK_CRCLIST requests the downloader must make.
std::vector<int> needed_images() const;
// GC_MARK_BLOCK entry: LZO1X-decompress `comp` (must expand to exactly
// MARK_BLOCK_PIXELS RGBA words) and blit it into image `img_idx` at
// `block_pos` (block_pos / 8 = row, block_pos % 8 = col). Returns false on a
// bad index or a decompression failure.
bool apply_block(int img_idx, int block_pos, const uint8_t *comp, uint32_t comp_len);
bool has_mark(uint32_t guild_id) const { return m_gid_mark.count(guild_id) != 0; }
uint32_t mark_id(uint32_t guild_id) const;
MarkRect rect_of(uint32_t guild_id) const;
// The whole 512x512 RGBA image (row-major, 0xAABBGGRR words), or empty.
const std::vector<uint32_t> &image(int img_idx) const;
// The 16x12 = 192 RGBA words for one guild's mark, or empty if unknown /
// not yet downloaded.
std::vector<uint32_t> mark_pixels(uint32_t guild_id) const;
int image_count() const { return (int)m_images.size(); }
size_t mark_count() const { return m_gid_mark.size(); }
void clear();
private:
std::vector<uint32_t> &touch_image(int img_idx);
std::map<uint32_t, uint32_t> m_gid_mark; // guild_id -> mark_id
std::map<int, std::vector<uint32_t>> m_images; // img_idx -> 512*512 RGBA
};
// Parse the body that follows the 10-byte GCMarkIDXList head: `count` pairs of
// { uint16_t guild_id; uint16_t mark_id }. Returns the number of pairs read.
size_t parse_mark_idxlist(const uint8_t *body, size_t n, uint16_t count, MarkImageSet &out);
// Parse the body that follows the 13-byte GCMarkBlock head: `count` entries of
// { uint8_t block_pos; uint32_t comp_size; uint8_t comp[comp_size] }. Returns
// the number of blocks successfully applied.
size_t parse_mark_block(const uint8_t *body, size_t n, int img_idx, uint32_t count, MarkImageSet &out);
} // namespace mtnet
+368
View File
@@ -0,0 +1,368 @@
#include "net_stream.h"
#include <arpa/inet.h>
#include <cerrno>
#include <cstdio>
#include <cstring>
#include <fcntl.h>
#include <netdb.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <sys/socket.h>
#include <unistd.h>
namespace mtnet {
namespace {
constexpr uint16_t MAX_PACKET_LENGTH = 65000;
void set_nonblocking(int fd) {
int fl = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, fl | O_NONBLOCK);
}
} // namespace
NetStream::~NetStream() {
disconnect();
}
void NetStream::set_state(State s) {
if (m_state == s) {
return;
}
m_state = s;
on_state_change(s);
}
bool NetStream::connect(const std::string &host, uint16_t port) {
if (!SecureCipher::ensure_sodium_init()) {
m_last_error = "sodium_init failed";
return false;
}
disconnect();
addrinfo hints{};
hints.ai_family = AF_INET;
hints.ai_socktype = SOCK_STREAM;
addrinfo *res = nullptr;
char portbuf[8];
std::snprintf(portbuf, sizeof(portbuf), "%u", port);
if (getaddrinfo(host.c_str(), portbuf, &hints, &res) != 0 || !res) {
m_last_error = "getaddrinfo(" + host + ") failed";
return false;
}
m_sock = ::socket(res->ai_family, res->ai_socktype, res->ai_protocol);
if (m_sock < 0) {
m_last_error = std::string("socket: ") + std::strerror(errno);
freeaddrinfo(res);
return false;
}
set_nonblocking(m_sock);
int one = 1;
setsockopt(m_sock, IPPROTO_TCP, TCP_NODELAY, &one, sizeof(one));
int rc = ::connect(m_sock, res->ai_addr, res->ai_addrlen);
freeaddrinfo(res);
if (rc == 0) {
set_state(State::Online);
} else if (errno == EINPROGRESS || errno == EWOULDBLOCK) {
set_state(State::Connecting);
} else {
m_last_error = std::string("connect: ") + std::strerror(errno);
::close(m_sock);
m_sock = -1;
return false;
}
m_last_error.clear();
return true;
}
void NetStream::disconnect() {
if (m_sock >= 0) {
::close(m_sock);
m_sock = -1;
}
m_recv.clear();
m_send.clear();
m_cipher.clean_up();
if (m_state != State::Offline) {
set_state(State::Offline);
}
}
bool NetStream::send_packet(const void *struct_bytes, size_t n) {
if (m_sock < 0 || n < PACKET_HEADER_SIZE) {
return false;
}
// copy so we can encrypt in place without touching the caller's struct
const auto *src = static_cast<const uint8_t *>(struct_bytes);
uint8_t stackbuf[512];
uint8_t *tmp = n <= sizeof(stackbuf) ? stackbuf : new uint8_t[n];
std::memcpy(tmp, src, n);
if (m_trace) {
const size_t shown = n < 96 ? n : 96;
std::fprintf(stderr, "[tx-plain %zuB]", n);
for (size_t i = 0; i < shown; ++i) {
std::fprintf(stderr, " %02x", tmp[i]);
}
if (shown != n) {
std::fprintf(stderr, " ...");
}
std::fprintf(stderr, "\n");
}
m_cipher.encrypt_in_place(tmp, n); // no-op until activated; advances tx counter
if (m_trace) {
const size_t shown = n < 96 ? n : 96;
std::fprintf(stderr, "[tx-wire %zuB]", n);
for (size_t i = 0; i < shown; ++i) {
std::fprintf(stderr, " %02x", tmp[i]);
}
if (shown != n) {
std::fprintf(stderr, " ...");
}
std::fprintf(stderr, "\n");
}
m_send.write(tmp, n);
if (tmp != stackbuf) {
delete[] tmp;
}
flush_send();
return true;
}
bool NetStream::recv_into_buffer() {
uint8_t chunk[8192];
ssize_t r = ::recv(m_sock, chunk, sizeof(chunk), 0);
if (r > 0) {
if (m_cipher.is_activated()) {
m_cipher.decrypt_in_place(chunk, static_cast<size_t>(r));
}
if (m_trace) {
size_t n = (size_t)r < 48 ? (size_t)r : 48;
std::fprintf(stderr, "[rx %zdB]", (ssize_t)r);
for (size_t i = 0; i < n; ++i) {
std::fprintf(stderr, " %02x", chunk[i]);
}
std::fprintf(stderr, "\n");
}
m_recv.write(chunk, static_cast<size_t>(r));
return true;
}
if (r == 0) {
m_last_error = "peer closed";
return false;
}
if (errno == EWOULDBLOCK || errno == EAGAIN) {
return true;
}
m_last_error = std::string("recv: ") + std::strerror(errno);
return false;
}
bool NetStream::flush_send() {
while (m_send.readable() > 0) {
ssize_t w = ::send(m_sock, m_send.read_ptr(), m_send.readable(), 0);
if (w > 0) {
m_send.discard(static_cast<size_t>(w));
continue;
}
if (w < 0 && (errno == EWOULDBLOCK || errno == EAGAIN)) {
return true; // try again next process()
}
m_last_error = std::string("send: ") + std::strerror(errno);
return false;
}
return true;
}
void NetStream::process() {
if (m_sock < 0) {
return;
}
fd_set rfd, wfd;
FD_ZERO(&rfd);
FD_ZERO(&wfd);
FD_SET(m_sock, &rfd);
FD_SET(m_sock, &wfd);
timeval tv{0, 0};
if (select(m_sock + 1, &rfd, &wfd, nullptr, &tv) < 0) {
return;
}
if (m_state == State::Connecting) {
if (FD_ISSET(m_sock, &wfd)) {
int err = 0;
socklen_t l = sizeof(err);
getsockopt(m_sock, SOL_SOCKET, SO_ERROR, &err, &l);
if (err != 0) {
m_last_error = std::string("connect: ") + std::strerror(err);
on_disconnect();
disconnect();
return;
}
set_state(State::Online);
} else {
return;
}
}
if (FD_ISSET(m_sock, &wfd) && m_send.readable() > 0) {
if (!flush_send()) {
on_disconnect();
disconnect();
return;
}
}
if (FD_ISSET(m_sock, &rfd)) {
if (!recv_into_buffer()) {
on_disconnect();
disconnect();
return;
}
}
if (!dispatch()) {
on_disconnect();
disconnect();
}
}
bool NetStream::dispatch() {
for (;;) {
PacketHeaderPeek hp;
if (!m_recv.peek(&hp, sizeof(hp))) {
return true; // need more bytes
}
// skip zero padding from cipher block alignment
if (hp.header == 0) {
uint16_t z;
m_recv.read(&z, sizeof(z));
continue;
}
// give a subclass first crack at framing this header (guild-mark stream).
switch (on_raw(hp.header)) {
case Raw::NeedMore:
return true;
case Raw::Consumed:
continue;
case Raw::Error:
return false;
case Raw::NotHandled:
break;
}
DynHeader dh;
if (!m_recv.peek(&dh, sizeof(dh))) {
return true;
}
if (dh.length < PACKET_HEADER_SIZE || dh.length > MAX_PACKET_LENGTH) {
m_last_error = "bad packet length " + std::to_string(dh.length) +
" for header 0x" + std::to_string(dh.header);
return false;
}
if (!m_recv.has(dh.length)) {
return true; // wait for the whole packet
}
if (m_trace) {
std::fprintf(stderr, "[frame] header=0x%04X length=%u avail=%zu\n", dh.header,
dh.length, m_recv.readable());
}
bool ok = true;
switch (dh.header) {
case GC_PHASE:
ok = handle_phase();
break;
case GC_PING:
ok = handle_ping();
break;
case GC_KEY_CHALLENGE:
ok = handle_key_challenge();
break;
case GC_KEY_COMPLETE:
ok = handle_key_complete();
break;
default: {
size_t before = m_recv.readable();
ok = on_packet(dh.header, dh.length);
size_t after = m_recv.readable();
if (m_trace) {
std::fprintf(stderr, "[frame] consumed %zu (expected %u)\n", before - after,
dh.length);
}
// safety: on_packet must consume at least the 4-byte frame header,
// otherwise dispatch() would spin on the same bytes forever.
if (ok && before == after) {
m_last_error = "on_packet consumed nothing for header 0x" +
std::to_string(dh.header);
return false;
}
break;
}
}
if (!ok) {
return false;
}
}
}
bool NetStream::handle_phase() {
GCPhase p;
if (!m_recv.read(&p, sizeof(p))) {
return false;
}
on_phase(p.phase);
return true;
}
bool NetStream::handle_ping() {
GCPing p;
if (!m_recv.read(&p, sizeof(p))) {
return false;
}
CGPong pong{CG_PONG, sizeof(CGPong)};
return send_packet(&pong, sizeof(pong));
}
bool NetStream::handle_key_challenge() {
GCKeyChallenge kc;
if (!m_recv.read(&kc, sizeof(kc))) {
return false;
}
if (!m_cipher.initialize() || !m_cipher.compute_client_keys(kc.server_pk)) {
m_last_error = "cipher key exchange failed";
return false;
}
CGKeyResponse resp{};
resp.header = CG_KEY_RESPONSE;
resp.length = sizeof(resp);
m_cipher.get_public_key(resp.client_pk);
m_cipher.compute_challenge_response(kc.challenge, resp.challenge_response);
return send_packet(&resp, sizeof(resp));
}
bool NetStream::handle_key_complete() {
GCKeyComplete kc;
if (!m_recv.read(&kc, sizeof(kc))) {
return false;
}
uint8_t token[SecureCipher::SESSION_TOKEN_SIZE];
if (!m_cipher.decrypt_token(kc.encrypted_token, sizeof(kc.encrypted_token), kc.nonce, token)) {
m_last_error = "session token decrypt failed";
return false;
}
m_cipher.set_session_token(token);
m_cipher.set_activated(true);
// bytes already buffered after this packet are the first ciphertext bytes;
// rx counter is 0, decrypt them in place now.
size_t pending = m_recv.readable();
if (pending > 0) {
m_cipher.decrypt_in_place(m_recv.mutable_unread(), pending);
}
on_cipher_active();
return true;
}
} // namespace mtnet
+92
View File
@@ -0,0 +1,92 @@
#pragma once
// NetStream — non-blocking TCP + Metin2 wire framing + libsodium handshake.
// POSIX sockets (macOS Phase 1). Ported from EterLib/NetStream.cpp + the
// control-plane handlers, minus Winsock and the Python phase glue.
//
// Lifecycle: connect() -> call process() every frame -> on_state_change /
// on_phase / on_packet callbacks fire. The base class handles GC_PHASE,
// GC_PING/CG_PONG and the KX handshake (GC_KEY_CHALLENGE / GC_KEY_COMPLETE)
// itself; subclasses handle everything else in on_packet().
#include "byte_buffer.h"
#include "secure_cipher.h"
#include "wire.h"
#include <cstdint>
#include <string>
namespace mtnet {
class NetStream {
public:
enum class State { Offline, Connecting, Online };
NetStream() = default;
virtual ~NetStream();
bool connect(const std::string &host, uint16_t port);
void disconnect();
void process(); // pump once per frame
State state() const { return m_state; }
bool is_online() const { return m_state == State::Online; }
bool cipher_active() const { return m_cipher.is_activated(); }
const uint8_t *session_token() const { return m_cipher.session_token(); }
const std::string &last_error() const { return m_last_error; }
void set_wire_trace(bool on) { m_trace = on; }
// Frame + (if active) encrypt `struct_bytes` and queue for send. `n` must be
// the full packet size; the first 4 bytes are [header][length].
bool send_packet(const void *struct_bytes, size_t n);
protected:
// Result of on_raw(): let a subclass frame packets the standard u16-length
// dispatcher can't (e.g. the guild-mark stream's u32 buf_size bodies).
enum class Raw { NotHandled, NeedMore, Consumed, Error };
// Overridable hooks.
virtual void on_state_change(State) {}
virtual void on_phase(uint8_t /*phase*/) {}
// Fired once, right after the KX handshake completes and the cipher turns on.
virtual void on_cipher_active() {}
// Called for every non-zero, non-control header BEFORE the standard u16-length
// gate. Inspect recv_ptr()/recv_avail(); consume via recv_bytes()/recv_discard().
// Return NotHandled to fall through to the normal on_packet() path.
virtual Raw on_raw(uint16_t /*header*/) { return Raw::NotHandled; }
// Non-control packet in the recv buffer. `len` bytes are guaranteed present.
// Consume exactly `len` bytes via recv_bytes(); return false to abort.
virtual bool on_packet(uint16_t /*header*/, uint16_t /*len*/) { return true; }
virtual void on_disconnect() {}
// Buffer accessors for on_packet() / on_raw() implementations.
bool peek_bytes(void *dst, size_t n) const { return m_recv.peek(dst, n); }
bool recv_bytes(void *dst, size_t n) { return m_recv.read(dst, n); }
const uint8_t *recv_ptr() const { return m_recv.read_ptr(); }
size_t recv_avail() const { return m_recv.readable(); }
void recv_discard(size_t n) { m_recv.discard(n); }
void drop_recv() { m_recv.clear(); }
// bytes still queued for send (not yet handed to the socket).
size_t send_pending() const { return m_send.readable(); }
private:
void set_state(State s);
bool recv_into_buffer(); // one recv() call
bool flush_send(); // one send() call
bool dispatch(); // consume complete packets from m_recv
bool handle_key_challenge();
bool handle_key_complete();
bool handle_ping();
bool handle_phase();
int m_sock = -1;
State m_state = State::Offline;
std::string m_last_error;
ByteBuffer m_recv;
ByteBuffer m_send;
SecureCipher m_cipher;
bool m_trace = false;
};
} // namespace mtnet
+141
View File
@@ -0,0 +1,141 @@
#include "secure_cipher.h"
namespace mtnet {
bool SecureCipher::ensure_sodium_init() {
static bool done = false;
if (!done) {
if (sodium_init() < 0) {
return false;
}
done = true;
}
return true;
}
bool SecureCipher::initialize() {
if (!ensure_sodium_init()) {
return false;
}
if (crypto_kx_keypair(m_pk, m_sk) != 0) {
return false;
}
m_tx_nonce = 0;
m_rx_nonce = 0;
m_initialized = true;
m_activated = false;
return true;
}
void SecureCipher::clean_up() {
sodium_memzero(m_pk, sizeof(m_pk));
sodium_memzero(m_sk, sizeof(m_sk));
sodium_memzero(m_tx_key, sizeof(m_tx_key));
sodium_memzero(m_rx_key, sizeof(m_rx_key));
sodium_memzero(m_tx_stream_nonce, sizeof(m_tx_stream_nonce));
sodium_memzero(m_rx_stream_nonce, sizeof(m_rx_stream_nonce));
sodium_memzero(m_session_token, sizeof(m_session_token));
m_initialized = false;
m_activated = false;
m_tx_nonce = 0;
m_rx_nonce = 0;
}
bool SecureCipher::compute_client_keys(const uint8_t *server_pk) {
if (!m_initialized) {
return false;
}
// client: rx_key decrypts S->C, tx_key encrypts C->S
if (crypto_kx_client_session_keys(m_rx_key, m_tx_key, m_pk, m_sk, server_pk) != 0) {
return false;
}
sodium_memzero(m_tx_stream_nonce, NONCE_SIZE);
m_tx_stream_nonce[0] = 0x02; // C->S
sodium_memzero(m_rx_stream_nonce, NONCE_SIZE);
m_rx_stream_nonce[0] = 0x01; // S->C
return true;
}
bool SecureCipher::compute_server_keys(const uint8_t *client_pk) {
if (!m_initialized) {
return false;
}
if (crypto_kx_server_session_keys(m_rx_key, m_tx_key, m_pk, m_sk, client_pk) != 0) {
return false;
}
sodium_memzero(m_tx_stream_nonce, NONCE_SIZE);
m_tx_stream_nonce[0] = 0x01; // S->C
sodium_memzero(m_rx_stream_nonce, NONCE_SIZE);
m_rx_stream_nonce[0] = 0x02; // C->S
return true;
}
void SecureCipher::compute_challenge_response(const uint8_t *challenge, uint8_t *out) const {
crypto_auth(out, challenge, CHALLENGE_SIZE, m_tx_key);
}
bool SecureCipher::verify_challenge_response(const uint8_t *challenge, const uint8_t *response) const {
return crypto_auth_verify(response, challenge, CHALLENGE_SIZE, m_rx_key) == 0;
}
void SecureCipher::apply_stream(void *buffer, size_t len, const uint8_t *key,
uint64_t &byte_counter, const uint8_t *stream_nonce) {
uint8_t *p = static_cast<uint8_t *>(buffer);
// partial leading block if the counter isn't 64-byte aligned
uint32_t offset = static_cast<uint32_t>(byte_counter % 64);
if (offset != 0 && len > 0) {
uint8_t ks[64];
sodium_memzero(ks, 64);
crypto_stream_xchacha20_xor_ic(ks, ks, 64, stream_nonce, byte_counter / 64, key);
size_t use = len < (64 - offset) ? len : (64 - offset);
for (size_t i = 0; i < use; ++i) {
p[i] ^= ks[offset + i];
}
p += use;
len -= use;
byte_counter += use;
}
if (len > 0) {
crypto_stream_xchacha20_xor_ic(p, p, static_cast<unsigned long long>(len), stream_nonce,
byte_counter / 64, key);
byte_counter += len;
}
}
void SecureCipher::encrypt_in_place(void *buffer, size_t len) {
if (!m_activated || len == 0) {
return;
}
apply_stream(buffer, len, m_tx_key, m_tx_nonce, m_tx_stream_nonce);
}
void SecureCipher::decrypt_in_place(void *buffer, size_t len) {
if (!m_activated || len == 0) {
return;
}
apply_stream(buffer, len, m_rx_key, m_rx_nonce, m_rx_stream_nonce);
}
bool SecureCipher::encrypt_token(const uint8_t *plaintext, size_t len, uint8_t *ciphertext,
uint8_t *nonce_out) const {
if (!m_initialized) {
return false;
}
randombytes_buf(nonce_out, NONCE_SIZE);
unsigned long long clen = 0;
return crypto_aead_xchacha20poly1305_ietf_encrypt(ciphertext, &clen, plaintext, len, nullptr,
0, nullptr, nonce_out, m_tx_key) == 0;
}
bool SecureCipher::decrypt_token(const uint8_t *ciphertext, size_t len, const uint8_t *nonce,
uint8_t *plaintext) const {
if (!m_initialized) {
return false;
}
unsigned long long plen = 0;
return crypto_aead_xchacha20poly1305_ietf_decrypt(plaintext, &plen, nullptr, ciphertext, len,
nullptr, 0, nonce, m_rx_key) == 0;
}
} // namespace mtnet
+99
View File
@@ -0,0 +1,99 @@
#pragma once
// SecureCipher — libsodium key exchange + stream cipher for the Metin2 net
// protocol (this m2dev fork). Ported near-verbatim from the client source
// EterBase/SecureCipher.{h,cpp}; only the logging calls were dropped so this
// has no dependency beyond libsodium (unit-testable standalone).
//
// Handshake (client side):
// recv GC_KEY_CHALLENGE{server_pk, challenge} -> Initialize(); ComputeClientKeys(server_pk)
// send CG_KEY_RESPONSE{GetPublicKey(), ComputeChallengeResponse(challenge)}
// recv GC_KEY_COMPLETE{encrypted_token, nonce} -> DecryptToken(); SetSessionToken(); SetActivated(true)
// After activation every byte on the wire is XChaCha20 keystream-XOR'd, per
// direction, with a running byte counter (order-sensitive).
#include <sodium.h>
#include <cstdint>
#include <cstring>
namespace mtnet {
class SecureCipher {
public:
static constexpr size_t PK_SIZE = crypto_kx_PUBLICKEYBYTES; // 32
static constexpr size_t SK_SIZE = crypto_kx_SECRETKEYBYTES; // 32
static constexpr size_t KEY_SIZE = crypto_kx_SESSIONKEYBYTES; // 32
static constexpr size_t NONCE_SIZE = crypto_aead_xchacha20poly1305_ietf_NPUBBYTES; // 24
static constexpr size_t TAG_SIZE = crypto_aead_xchacha20poly1305_ietf_ABYTES; // 16
static constexpr size_t CHALLENGE_SIZE = 32;
static constexpr size_t SESSION_TOKEN_SIZE = 32;
SecureCipher() {
sodium_memzero(m_pk, sizeof(m_pk));
sodium_memzero(m_sk, sizeof(m_sk));
sodium_memzero(m_tx_key, sizeof(m_tx_key));
sodium_memzero(m_rx_key, sizeof(m_rx_key));
sodium_memzero(m_tx_stream_nonce, sizeof(m_tx_stream_nonce));
sodium_memzero(m_rx_stream_nonce, sizeof(m_rx_stream_nonce));
sodium_memzero(m_session_token, sizeof(m_session_token));
}
~SecureCipher() { clean_up(); }
static bool ensure_sodium_init();
// Generate this endpoint's X25519 keypair.
bool initialize();
void clean_up();
void get_public_key(uint8_t *out_pk) const { memcpy(out_pk, m_pk, PK_SIZE); }
// Derive session keys. Client uses the server's public key; server uses the
// client's. Sets the fixed per-direction stream nonces (0x01 = S->C, 0x02 = C->S).
bool compute_client_keys(const uint8_t *server_pk);
bool compute_server_keys(const uint8_t *client_pk);
// HMAC(challenge, tx_key). Peer verifies with its rx_key (== our tx_key).
void compute_challenge_response(const uint8_t *challenge, uint8_t *out_response) const;
bool verify_challenge_response(const uint8_t *challenge, const uint8_t *response) const;
// In-place XChaCha20 keystream XOR for wire buffers. Same length in/out; the
// running byte counter must advance over exactly the bytes sent/received, in
// order. No-op until activated.
void encrypt_in_place(void *buffer, size_t len);
void decrypt_in_place(void *buffer, size_t len);
// One-shot AEAD (XChaCha20-Poly1305) for the KeyComplete session token.
bool encrypt_token(const uint8_t *plaintext, size_t len, uint8_t *ciphertext,
uint8_t *nonce_out) const;
bool decrypt_token(const uint8_t *ciphertext, size_t len, const uint8_t *nonce,
uint8_t *plaintext) const;
bool is_activated() const { return m_activated; }
void set_activated(bool v) { m_activated = v; }
bool is_initialized() const { return m_initialized; }
void set_session_token(const uint8_t *token) { memcpy(m_session_token, token, SESSION_TOKEN_SIZE); }
const uint8_t *session_token() const { return m_session_token; }
uint64_t tx_nonce() const { return m_tx_nonce; }
uint64_t rx_nonce() const { return m_rx_nonce; }
private:
void apply_stream(void *buffer, size_t len, const uint8_t *key,
uint64_t &byte_counter, const uint8_t *stream_nonce);
bool m_initialized = false;
bool m_activated = false;
uint8_t m_pk[PK_SIZE];
uint8_t m_sk[SK_SIZE];
uint8_t m_tx_key[KEY_SIZE];
uint8_t m_rx_key[KEY_SIZE];
uint64_t m_tx_nonce = 0;
uint64_t m_rx_nonce = 0;
uint8_t m_tx_stream_nonce[NONCE_SIZE];
uint8_t m_rx_stream_nonce[NONCE_SIZE];
uint8_t m_session_token[SESSION_TOKEN_SIZE];
};
} // namespace mtnet
File diff suppressed because it is too large Load Diff
+83
View File
@@ -0,0 +1,83 @@
#include "asset_source.h"
#include <cstdio>
#include <filesystem>
#include <fstream>
namespace fs = std::filesystem;
namespace mtpack {
bool AssetSource::build(const std::string &assets_root, const std::string &pack_dir,
const std::string &cache_dir, std::string *err) {
if (!m_loose.build(assets_root, fmt::AssetResolver::default_priority(), err)) {
return false;
}
if (!pack_dir.empty()) {
int n = m_packs.scan_dir(pack_dir);
m_have_packs = n > 0;
m_cache = !cache_dir.empty()
? cache_dir
: (fs::path(pack_dir) / ".mtcache").string();
}
return true;
}
bool AssetSource::exists(const std::string &vpath) const {
if (!m_loose.resolve(vpath).empty()) {
return true;
}
return m_have_packs && m_packs.has(vpath);
}
bool AssetSource::read(const std::string &vpath, std::vector<uint8_t> &out, std::string *err) const {
std::string lp = m_loose.resolve(vpath);
if (!lp.empty()) {
std::ifstream f(lp, std::ios::binary);
if (f) {
out.assign((std::istreambuf_iterator<char>(f)), std::istreambuf_iterator<char>());
return true;
}
}
if (m_have_packs && m_packs.read(vpath, out, err)) {
return true;
}
if (err && err->empty()) {
*err = "not found: " + vpath;
}
return false;
}
std::string AssetSource::to_path(const std::string &vpath, std::string *err) const {
std::string lp = m_loose.resolve(vpath);
if (!lp.empty()) {
return lp;
}
if (!m_have_packs || !m_packs.has(vpath)) {
if (err) *err = "not found: " + vpath;
return "";
}
// extract to cache/<normalized vpath>, once
std::string rel = PackMount::norm(vpath);
fs::path dst = fs::path(m_cache) / rel;
std::error_code ec;
if (fs::exists(dst, ec) && fs::file_size(dst, ec) > 0) {
return dst.string();
}
std::vector<uint8_t> bytes;
if (!m_packs.read(vpath, bytes, err)) {
return "";
}
fs::create_directories(dst.parent_path(), ec);
std::ofstream o(dst, std::ios::binary | std::ios::trunc);
if (!o) {
if (err) *err = "cannot write cache file " + dst.string();
return "";
}
o.write(reinterpret_cast<const char *>(bytes.data()),
static_cast<std::streamsize>(bytes.size()));
o.close();
return dst.string();
}
} // namespace mtpack
+48
View File
@@ -0,0 +1,48 @@
#pragma once
// AssetSource —— unified asset lookup over loose files + .epk packs.
//
// src.build(assets_root, pack_dir);
// auto p = src.to_path("d:/ymir work/ui/pattern/board_base.tga");
// // p is a real filesystem path: the loose file if present, else the pack
// // entry extracted once into <cache>/... . Callers that expect a path keep
// // working; new code can use read() for bytes directly.
//
// Precedence (dev-friendly, matches the client's pack/ overlay): a loose file
// wins over a packed one. Within packs, later-mounted (patch) wins.
#include "asset_resolver.h" // xrender::formats
#include "pack_mount.h"
#include <cstdint>
#include <string>
#include <vector>
namespace mtpack {
class AssetSource {
public:
// `pack_dir` may be empty (loose-only). `cache_dir` is where packed files
// get extracted for to_path(); defaults to <pack_dir>/.mtcache or a temp dir.
bool build(const std::string &assets_root, const std::string &pack_dir = "",
const std::string &cache_dir = "", std::string *err = nullptr);
bool exists(const std::string &vpath) const;
// Real path for `vpath`: loose file, or a pack entry extracted to the cache.
// Empty string if not found anywhere.
std::string to_path(const std::string &vpath, std::string *err = nullptr) const;
// Bytes for `vpath` (loose read or pack decompress). false if not found.
bool read(const std::string &vpath, std::vector<uint8_t> &out, std::string *err = nullptr) const;
const fmt::AssetResolver &loose() const { return m_loose; }
const PackMount &packs() const { return m_packs; }
private:
fmt::AssetResolver m_loose;
PackMount m_packs;
std::string m_cache;
bool m_have_packs = false;
};
} // namespace mtpack
+266
View File
@@ -0,0 +1,266 @@
#include "eterpack.h"
#include <sodium.h>
#include <zstd.h>
#include <algorithm>
#include <cstring>
#include <fstream>
namespace mtpack {
namespace {
constexpr size_t HEADER_SIZE = 8 + 8 + PACK_NONCE_SIZE; // 40
// entry tail after the name field: offset,file_size,compressed_size,encryption,nonce
constexpr size_t ENTRY_TAIL = 8 + 8 + 8 + 1 + PACK_NONCE_SIZE; // 49
void xchacha20(uint8_t *data, size_t len, const uint8_t *nonce) {
crypto_stream_xchacha20_xor(data, data, len, nonce, PACK_KEY);
}
// Parse one decrypted entry blob of `field + ENTRY_TAIL` bytes.
Entry parse_entry(const uint8_t *p, int field) {
Entry e;
size_t nlen = strnlen(reinterpret_cast<const char *>(p), field);
e.name.assign(reinterpret_cast<const char *>(p), nlen);
const uint8_t *q = p + field;
std::memcpy(&e.offset, q, 8);
q += 8;
std::memcpy(&e.file_size, q, 8);
q += 8;
std::memcpy(&e.compressed_size, q, 8);
q += 8;
e.encryption = *q++;
std::memcpy(e.nonce, q, PACK_NONCE_SIZE);
return e;
}
bool plausible(const Entry &e, uint64_t data_begin, uint64_t file_total) {
if (e.encryption > 1) {
return false;
}
// empty files are legal; a zstd frame is never 0 bytes though
if (e.compressed_size == 0 || e.compressed_size > file_total) {
return false;
}
if (data_begin + e.offset + e.compressed_size > file_total) {
return false;
}
if (e.name.empty()) {
return false;
}
// filenames may be CP949 (Korean) — reject only ASCII control bytes.
for (unsigned char c : e.name) {
if (c < 0x20) {
return false;
}
}
return true;
}
} // namespace
std::string EterPack::norm(std::string s) {
std::replace(s.begin(), s.end(), '\\', '/');
std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c) { return std::tolower(c); });
return s;
}
void EterPack::close() {
m_file.clear();
m_index.clear();
m_by_name.clear();
m_data_begin = 0;
}
bool EterPack::open(const std::string &path, std::string *err) {
close();
if (sodium_init() < 0) {
if (err) *err = "sodium_init failed";
return false;
}
std::ifstream f(path, std::ios::binary);
if (!f) {
if (err) *err = "cannot open " + path;
return false;
}
f.seekg(0, std::ios::end);
std::streamoff sz = f.tellg();
f.seekg(0);
if (sz < static_cast<std::streamoff>(HEADER_SIZE)) {
if (err) *err = "file too small";
return false;
}
m_file.resize(static_cast<size_t>(sz));
f.read(reinterpret_cast<char *>(m_file.data()), sz);
uint64_t entry_num = 0;
std::memcpy(&entry_num, m_file.data(), 8);
std::memcpy(&m_data_begin, m_file.data() + 8, 8);
const uint8_t *hnonce = m_file.data() + 16;
if (entry_num == 0 || m_data_begin < HEADER_SIZE || m_data_begin > m_file.size()) {
if (err) *err = "bad header";
return false;
}
const uint64_t index_bytes = m_data_begin - HEADER_SIZE;
// Candidate name-field sizes: derived first, then the known platform values.
std::vector<int> candidates;
if (index_bytes % entry_num == 0) {
int64_t es = static_cast<int64_t>(index_bytes / entry_num);
if (es > static_cast<int64_t>(ENTRY_TAIL) + 1) {
candidates.push_back(static_cast<int>(es - ENTRY_TAIL));
}
}
for (int v : {261, 4097, 1025, 256}) {
candidates.push_back(v);
}
for (int field : candidates) {
const size_t entry_size = static_cast<size_t>(field) + ENTRY_TAIL;
if (HEADER_SIZE + entry_num * entry_size > m_file.size()) {
continue;
}
std::vector<Entry> idx;
idx.reserve(entry_num);
bool ok = true;
std::vector<uint8_t> blob(entry_size);
for (uint64_t i = 0; i < entry_num && ok; ++i) {
std::memcpy(blob.data(), m_file.data() + HEADER_SIZE + i * entry_size, entry_size);
xchacha20(blob.data(), entry_size, hnonce);
Entry e = parse_entry(blob.data(), field);
if (!plausible(e, m_data_begin, m_file.size())) {
ok = false;
break;
}
idx.push_back(std::move(e));
}
if (ok) {
m_index = std::move(idx);
m_name_field = field;
for (size_t i = 0; i < m_index.size(); ++i) {
m_by_name[norm(m_index[i].name)] = i;
}
return true;
}
}
if (err) *err = "could not resolve index layout (name-field guess failed)";
return false;
}
std::vector<std::string> EterPack::names() const {
std::vector<std::string> v;
v.reserve(m_index.size());
for (const auto &e : m_index) {
v.push_back(e.name);
}
return v;
}
bool EterPack::read(const std::string &name, std::vector<uint8_t> &out, std::string *err) const {
auto it = m_by_name.find(norm(name));
if (it == m_by_name.end()) {
if (err) *err = "not in pack: " + name;
return false;
}
const Entry &e = m_index[it->second];
const uint8_t *src = m_file.data() + m_data_begin + e.offset;
std::vector<uint8_t> comp(e.compressed_size);
std::memcpy(comp.data(), src, e.compressed_size);
if (e.encryption == 1) {
crypto_stream_xchacha20_xor(comp.data(), comp.data(), comp.size(), e.nonce, PACK_KEY);
}
out.resize(e.file_size);
size_t n = ZSTD_decompress(out.data(), out.size(), comp.data(), comp.size());
if (ZSTD_isError(n) || n != e.file_size) {
if (err) *err = std::string("zstd: ") + (ZSTD_isError(n) ? ZSTD_getErrorName(n) : "size mismatch");
return false;
}
return true;
}
// --- writer ----------------------------------------------------------------
bool write_pack(const std::string &out_path, const std::vector<InputFile> &files, bool encrypt,
std::string *err) {
if (sodium_init() < 0) {
if (err) *err = "sodium_init failed";
return false;
}
const int field = PACK_NAME_FIELD_DEFAULT;
const size_t entry_size = static_cast<size_t>(field) + ENTRY_TAIL;
const uint64_t entry_num = files.size();
const uint64_t data_begin = HEADER_SIZE + entry_num * entry_size;
uint8_t header_nonce[PACK_NONCE_SIZE];
randombytes_buf(header_nonce, sizeof(header_nonce));
std::vector<uint8_t> index(entry_num * entry_size, 0);
std::vector<uint8_t> data;
uint64_t cursor = 0;
for (uint64_t i = 0; i < entry_num; ++i) {
const InputFile &in = files[i];
size_t bound = ZSTD_compressBound(in.data.size());
std::vector<uint8_t> comp(bound);
size_t clen = ZSTD_compress(comp.data(), comp.size(), in.data.data(), in.data.size(), 3);
if (ZSTD_isError(clen)) {
if (err) *err = std::string("zstd compress: ") + ZSTD_getErrorName(clen);
return false;
}
comp.resize(clen);
Entry e;
e.name = in.name;
e.offset = cursor;
e.file_size = in.data.size();
e.compressed_size = clen;
e.encryption = encrypt ? 1 : 0;
if (encrypt) {
randombytes_buf(e.nonce, sizeof(e.nonce));
crypto_stream_xchacha20_xor(comp.data(), comp.data(), comp.size(), e.nonce, PACK_KEY);
}
// serialize entry (plaintext), then encrypt the whole index blob at the end
uint8_t *p = index.data() + i * entry_size;
std::string nm = e.name;
std::replace(nm.begin(), nm.end(), '\\', '/');
std::memcpy(p, nm.data(), std::min<size_t>(nm.size(), field - 1));
uint8_t *q = p + field;
std::memcpy(q, &e.offset, 8);
q += 8;
std::memcpy(q, &e.file_size, 8);
q += 8;
std::memcpy(q, &e.compressed_size, 8);
q += 8;
*q++ = e.encryption;
std::memcpy(q, e.nonce, PACK_NONCE_SIZE);
data.insert(data.end(), comp.begin(), comp.end());
cursor += clen;
}
// encrypt the index with the header nonce
for (uint64_t i = 0; i < entry_num; ++i) {
crypto_stream_xchacha20_xor(index.data() + i * entry_size, index.data() + i * entry_size,
entry_size, header_nonce, PACK_KEY);
}
std::ofstream f(out_path, std::ios::binary | std::ios::trunc);
if (!f) {
if (err) *err = "cannot write " + out_path;
return false;
}
f.write(reinterpret_cast<const char *>(&entry_num), 8);
f.write(reinterpret_cast<const char *>(&data_begin), 8);
f.write(reinterpret_cast<const char *>(header_nonce), PACK_NONCE_SIZE);
f.write(reinterpret_cast<const char *>(index.data()), static_cast<std::streamsize>(index.size()));
f.write(reinterpret_cast<const char *>(data.data()), static_cast<std::streamsize>(data.size()));
return static_cast<bool>(f);
}
} // namespace mtpack
+83
View File
@@ -0,0 +1,83 @@
#pragma once
// EterPack — reader/writer for this m2dev fork's single-file asset pack
// (PackLib/Pack.cpp). NOT the classic Metin2 .eix/.epk format.
//
// Layout:
// [Header: u64 entry_num, u64 data_begin, u8 nonce[24]] (40 bytes)
// [Entry x entry_num] each XChaCha20-encrypted with Header.nonce + PACK_KEY
// Entry: char name[NAME_FIELD], u64 offset, u64 file_size,
// u64 compressed_size, u8 encryption, u8 nonce[24]
// [data blob @ data_begin]
// per file @ data_begin+offset, compressed_size bytes:
// encryption==0 -> zstd frame -> zstd-decompress to file_size
// encryption==1 -> XChaCha20(entry.nonce)-> zstd-decompress to file_size
//
// NAME_FIELD is FILENAME_MAX+1 on the packer's platform (MSVC PackMaker = 261).
// The reader derives it from (data_begin - 40) / entry_num, falling back to the
// known platform values, so it stays compatible with real packs.
#include <cstdint>
#include <string>
#include <unordered_map>
#include <vector>
namespace mtpack {
inline constexpr int PACK_NONCE_SIZE = 24;
inline constexpr int PACK_KEY_SIZE = 32;
// PackLib/config.h PACK_KEY — the fork ships this hardcoded.
inline constexpr uint8_t PACK_KEY[PACK_KEY_SIZE] = {
0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77,
0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF,
0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF,
0xFE, 0xDC, 0xBA, 0x98, 0x76, 0x54, 0x32, 0x10
};
// MSVC PackMaker.exe: FILENAME_MAX == 260, so the name field is 261 bytes.
inline constexpr int PACK_NAME_FIELD_DEFAULT = 261;
struct Entry {
std::string name;
uint64_t offset = 0;
uint64_t file_size = 0;
uint64_t compressed_size = 0;
uint8_t encryption = 0;
uint8_t nonce[PACK_NONCE_SIZE] = {};
};
class EterPack {
public:
// Load the index (mmaps the file; keeps it open for read()).
bool open(const std::string &path, std::string *err = nullptr);
void close();
bool has(const std::string &name) const { return m_by_name.count(norm(name)) != 0; }
std::vector<std::string> names() const;
size_t count() const { return m_index.size(); }
// Decompress (+decrypt) one file into `out`.
bool read(const std::string &name, std::vector<uint8_t> &out, std::string *err = nullptr) const;
int name_field() const { return m_name_field; }
private:
static std::string norm(std::string s); // '\\'->'/', lowercase
std::vector<uint8_t> m_file; // whole pack in memory (simple; mmap later)
uint64_t m_data_begin = 0;
std::vector<Entry> m_index;
std::unordered_map<std::string, size_t> m_by_name;
int m_name_field = PACK_NAME_FIELD_DEFAULT;
};
// Build a pack from (name, bytes) pairs. `encrypt` -> per-file encryption==1.
// Uses PACK_NAME_FIELD_DEFAULT so real clients / PackLib can read it back.
struct InputFile {
std::string name;
std::vector<uint8_t> data;
};
bool write_pack(const std::string &out_path, const std::vector<InputFile> &files, bool encrypt,
std::string *err = nullptr);
} // namespace mtpack
+113
View File
@@ -0,0 +1,113 @@
#include "pack_mount.h"
#include <algorithm>
#include <filesystem>
namespace fs = std::filesystem;
namespace mtpack {
std::string PackMount::norm(std::string s) {
std::replace(s.begin(), s.end(), '\\', '/');
// strip a leading "x:/" drive
if (s.size() > 2 && s[1] == ':') {
s = s.substr(2);
}
while (!s.empty() && s.front() == '/') {
s.erase(s.begin());
}
std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c) { return std::tolower(c); });
// fold "//"
std::string o;
o.reserve(s.size());
for (char c : s) {
if (c == '/' && !o.empty() && o.back() == '/') {
continue;
}
o.push_back(c);
}
return o;
}
bool PackMount::mount(const std::string &epk_path, std::string *err) {
auto pk = std::make_shared<EterPack>();
if (!pk->open(epk_path, err)) {
return false;
}
const size_t pi = m_packs.size();
m_packs.push_back(pk);
for (const std::string &name : pk->names()) {
std::string n = norm(name);
m_by_norm[n] = {pi, name}; // later pack wins
auto p = n.find("ymir work/");
if (p != std::string::npos) {
m_by_ymir[n.substr(p + 10)] = {pi, name};
}
}
return true;
}
int PackMount::scan_dir(const std::string &dir, const std::string &patch_prefix) {
std::error_code ec;
if (!fs::is_directory(dir, ec)) {
return 0;
}
std::vector<std::string> base, patch;
for (const auto &e : fs::directory_iterator(dir, ec)) {
if (!e.is_regular_file()) {
continue;
}
const auto &p = e.path();
if (p.extension() != ".epk") {
continue;
}
if (!patch_prefix.empty() && p.filename().string().rfind(patch_prefix, 0) == 0) {
patch.push_back(p.string());
} else {
base.push_back(p.string());
}
}
std::sort(base.begin(), base.end());
std::sort(patch.begin(), patch.end());
int n = 0;
for (const auto &v : {&base, &patch}) {
for (const std::string &f : *v) {
if (mount(f)) {
++n;
}
}
}
return n;
}
bool PackMount::has(const std::string &vp) const {
std::string n = norm(vp);
if (m_by_norm.count(n)) {
return true;
}
auto p = n.find("ymir work/");
std::string suf = (p != std::string::npos) ? n.substr(p + 10) : n;
return m_by_ymir.count(suf) != 0;
}
bool PackMount::read(const std::string &vp, std::vector<uint8_t> &out, std::string *err) const {
std::string n = norm(vp);
const Ref *r = nullptr;
if (auto it = m_by_norm.find(n); it != m_by_norm.end()) {
r = &it->second;
} else {
auto p = n.find("ymir work/");
std::string suf = (p != std::string::npos) ? n.substr(p + 10) : n;
if (auto it2 = m_by_ymir.find(suf); it2 != m_by_ymir.end()) {
r = &it2->second;
}
}
if (!r) {
if (err) *err = "not mounted: " + vp;
return false;
}
return m_packs[r->pack]->read(r->name, out, err);
}
} // namespace mtpack
+53
View File
@@ -0,0 +1,53 @@
#pragma once
// PackMount —— mount one or more .epk packs and resolve/read virtual paths
// (`d:\ymir work\...`) out of them, mirroring fmt::AssetResolver's normalization
// and "ymir work/"-suffix indexing so packed and loose assets share one lookup.
//
// Priority: packs mounted later win (call order = base packs first, patches
// last), matching the loose-file resolver's pack_priority convention.
#include "eterpack.h"
#include <memory>
#include <string>
#include <unordered_map>
#include <vector>
namespace mtpack {
class PackMount {
public:
// Open one pack and add its entries to the index. Returns false if it
// won't open; missing files are not an error for scan_dir().
bool mount(const std::string &epk_path, std::string *err = nullptr);
// Open every *.epk directly under `dir` (non-recursive), sorted by name so
// mount order is deterministic. `patch_prefix` packs (name starts with it)
// are mounted after the rest, so they override.
int scan_dir(const std::string &dir, const std::string &patch_prefix = "metin2_patch_");
bool has(const std::string &virtual_path) const;
// Decompress the file into `out`. Tries the full normalized path, then the
// "ymir work/..." suffix.
bool read(const std::string &virtual_path, std::vector<uint8_t> &out,
std::string *err = nullptr) const;
size_t pack_count() const { return m_packs.size(); }
size_t entry_count() const { return m_by_norm.size(); }
// path normalization shared with fmt::AssetResolver: '\'->'/', strip drive,
// strip leading '/', lowercase, fold '//'.
static std::string norm(std::string s);
private:
struct Ref {
size_t pack; // index into m_packs
std::string name; // exact entry name in that pack
};
std::vector<std::shared_ptr<EterPack>> m_packs;
std::unordered_map<std::string, Ref> m_by_norm; // full normalized path
std::unordered_map<std::string, Ref> m_by_ymir; // after "ymir work/"
};
} // namespace mtpack
+228
View File
@@ -0,0 +1,228 @@
#include "proto.h"
#include <sodium.h>
#include <cstring>
#include <fstream>
extern "C" {
#include <lzo/lzo1x.h>
}
namespace mtproto {
namespace {
constexpr uint32_t FOURCC_MIPX = 0x5850494D; // "MIPX" bytes 4D 49 50 58 (LE u32)
constexpr uint32_t FOURCC_MIPT = 0x5450494D; // "MIPT"
constexpr uint32_t FOURCC_MMPT = 0x54504D4D; // "MMPT"
constexpr uint32_t FOURCC_MCOZ = 0x5A4F434D; // "MCOZ"
uint32_t rd_u32(const uint8_t *p) {
uint32_t v;
std::memcpy(&v, p, 4);
return v;
}
// The fork's tea_decrypt (EterBase/tea.cpp): XChaCha20 with key/nonce derived from
// the 16-byte input key via BLAKE2b. size is rounded up to a multiple of 8.
void tea_decrypt(uint8_t *dst, const uint8_t *src, const std::array<uint32_t, 4> &key32,
size_t size) {
uint8_t key16[16];
std::memcpy(key16, key32.data(), 16); // 4 LE dwords -> raw bytes
uint8_t dkey[crypto_stream_xchacha20_KEYBYTES];
uint8_t nonce[crypto_stream_xchacha20_NONCEBYTES];
crypto_generichash(dkey, sizeof(dkey), key16, 16,
reinterpret_cast<const uint8_t *>("M2DevPackEncrypt"), 16);
uint8_t nonce_seed[crypto_stream_xchacha20_NONCEBYTES + 8];
crypto_generichash(nonce_seed, sizeof(nonce_seed), key16, 16,
reinterpret_cast<const uint8_t *>("M2DevNonce"), 10);
std::memcpy(nonce, nonce_seed, sizeof(nonce));
size_t rs = (size % 8 == 0) ? size : size + 8 - (size % 8);
crypto_stream_xchacha20_xor(dst, src, rs, nonce, dkey);
sodium_memzero(dkey, sizeof(dkey));
}
// CLZO container -> decompressed bytes.
bool clzo_decompress(const uint8_t *blob, size_t blob_len, const std::array<uint32_t, 4> &key,
std::vector<uint8_t> &out, std::string *err) {
if (blob_len < 20 || rd_u32(blob) != FOURCC_MCOZ) {
if (err) *err = "CLZO: bad MCOZ header";
return false;
}
const uint32_t enc_size = rd_u32(blob + 4);
const uint32_t comp_size = rd_u32(blob + 8);
const uint32_t real_size = rd_u32(blob + 12);
out.assign(real_size, 0);
lzo_uint out_len = real_size;
int r;
if (enc_size > 0) {
size_t rs = (enc_size % 8 == 0) ? enc_size : enc_size + 8 - (enc_size % 8);
if (16 + rs > blob_len) {
if (err) *err = "CLZO: encrypted region past end";
return false;
}
std::vector<uint8_t> dec(rs);
tea_decrypt(dec.data(), blob + 16, key, enc_size); // src = blob+16 (== m_pbIn-4)
if (rd_u32(dec.data()) != FOURCC_MCOZ) {
if (err) *err = "CLZO: wrong key (inner MCOZ mismatch)";
return false;
}
r = lzo1x_decompress_safe(dec.data() + 4, comp_size, out.data(), &out_len, nullptr);
} else {
if (20u + comp_size > blob_len) {
if (err) *err = "CLZO: compressed region past end";
return false;
}
r = lzo1x_decompress_safe(blob + 20, comp_size, out.data(), &out_len, nullptr);
}
if (r != LZO_E_OK) {
if (err) *err = "CLZO: lzo1x_decompress_safe failed (" + std::to_string(r) + ")";
return false;
}
if (out_len != real_size) {
if (err) *err = "CLZO: size mismatch " + std::to_string(out_len) + " != " +
std::to_string(real_size);
return false;
}
return true;
}
std::string cstr(const uint8_t *p, size_t maxn) {
size_t n = 0;
while (n < maxn && p[n]) {
++n;
}
return std::string(reinterpret_cast<const char *>(p), n);
}
} // namespace
bool load_proto(const std::string &path, const std::array<uint32_t, 4> &key, Proto &out,
std::string *err) {
std::ifstream f(path, std::ios::binary);
if (!f) {
if (err) *err = "cannot open " + path;
return false;
}
std::vector<uint8_t> file((std::istreambuf_iterator<char>(f)), std::istreambuf_iterator<char>());
return load_proto_bytes(file, key, out, err);
}
bool load_proto_bytes(const std::vector<uint8_t> &file, const std::array<uint32_t, 4> &key,
Proto &out, std::string *err) {
if (sodium_init() < 0) {
if (err) *err = "sodium_init failed";
return false;
}
if (lzo_init() != LZO_E_OK) {
if (err) *err = "lzo_init failed";
return false;
}
if (file.size() < 16) {
if (err) *err = "file too small";
return false;
}
const uint8_t *p = file.data();
out.fourcc = rd_u32(p);
p += 4;
uint32_t data_size = 0;
if (out.fourcc == FOURCC_MIPX) {
out.version = rd_u32(p);
p += 4;
out.stride = rd_u32(p);
p += 4;
out.elements = rd_u32(p);
p += 4;
data_size = rd_u32(p);
p += 4;
if (out.version != 1) {
if (err) *err = "MIPX version != 1";
return false;
}
} else if (out.fourcc == FOURCC_MIPT || out.fourcc == FOURCC_MMPT) {
out.elements = rd_u32(p);
p += 4;
data_size = rd_u32(p);
p += 4;
} else {
if (err) *err = "unknown proto fourcc";
return false;
}
if (static_cast<size_t>(p - file.data()) + data_size > file.size()) {
if (err) *err = "declared data_size past end of file";
return false;
}
if (!clzo_decompress(p, data_size, key, out.blob, err)) {
return false;
}
if (out.elements == 0) {
if (err) *err = "0 elements";
return false;
}
if (out.stride == 0) {
if (out.blob.size() % out.elements != 0) {
if (err) *err = "blob not divisible by element count";
return false;
}
out.stride = static_cast<uint32_t>(out.blob.size() / out.elements);
}
if (static_cast<size_t>(out.stride) * out.elements != out.blob.size()) {
if (err) *err = "stride * elements != blob size";
return false;
}
return true;
}
ItemRecord parse_item(const uint8_t *r, uint32_t stride) {
ItemRecord it;
if (!r || stride < 236) {
return it;
}
it.vnum = rd_u32(r + 0);
it.vnum_range = rd_u32(r + 4);
it.name = cstr(r + 8, 65);
it.locale_name = cstr(r + 73, 65);
it.type = r[138];
it.sub_type = r[139];
it.weight = r[140];
it.size = r[141];
it.wear_flags = rd_u32(r + 150);
it.buy_price = rd_u32(r + 158);
it.sell_price = rd_u32(r + 162);
// aLimits[2] (5B each) @166, aApplies[3] (5B each) @176, alValues[6] @191.
// (bSpecular @234 anchors the whole chain.)
for (int i = 0; i < 6; ++i) {
it.values[i] = (int32_t)rd_u32(r + 191 + i * 4);
}
it.specular = r[234];
return it;
}
MobRecord parse_mob(const uint8_t *r, uint32_t stride) {
MobRecord m;
if (!r || stride < 139) {
return m;
}
m.vnum = rd_u32(r + 0);
m.name = cstr(r + 4, 65);
m.locale_name = cstr(r + 69, 65);
m.type = r[134];
m.rank = r[135];
m.battle_type = r[136];
m.level = r[137];
m.size = r[138];
return m;
}
} // namespace mtproto
+75
View File
@@ -0,0 +1,75 @@
#pragma once
// item_proto / mob_proto reader for this m2dev fork.
//
// Outer: MIPX (item) = [u32 'MIPX'][u32 ver=1][u32 stride][u32 elements][u32 datasize][blob]
// MMPT (mob) = [u32 'MMPT'][u32 elements][u32 datasize][blob]
// Blob = CLZO container: [u32 'MCOZ'][u32 encryptSize][u32 compressedSize][u32 realSize]
// data @ blob+20. encryptSize>0 -> XChaCha20-decrypt (the fork's tea_*: key =
// BLAKE2b(key16,"M2DevPackEncrypt"), nonce = BLAKE2b(key16,"M2DevNonce")[:24]) of
// encryptSize bytes starting at blob+16, yielding [u32 'MCOZ'][lzo1x] -> LZO ->
// realSize bytes. encryptSize==0 -> LZO straight from blob+20.
// Decompressed blob = `elements` records of `stride` bytes (stride from the MIPX header,
// or realSize/elements for MMPT). Records are #pragma pack(1).
#include <array>
#include <cstdint>
#include <string>
#include <vector>
namespace mtproto {
// The 4-DWORD keys the client hardcodes (GameLib/ItemManager.cpp, PythonNonPlayer.cpp).
inline constexpr std::array<uint32_t, 4> ITEM_PROTO_KEY = {173217u, 72619434u, 408587239u, 27973291u};
inline constexpr std::array<uint32_t, 4> MOB_PROTO_KEY = {4813894u, 18955u, 552631u, 6822045u};
struct Proto {
uint32_t fourcc = 0;
uint32_t version = 0;
uint32_t stride = 0; // record size
uint32_t elements = 0; // record count
std::vector<uint8_t> blob; // elements * stride bytes
const uint8_t *record(uint32_t i) const {
return (i < elements) ? blob.data() + static_cast<size_t>(i) * stride : nullptr;
}
};
// Load + decompress. `key` is ITEM_PROTO_KEY or MOB_PROTO_KEY.
// Parse from an already-read buffer (host reads via godot::FileAccess for res://).
bool load_proto_bytes(const std::vector<uint8_t> &bytes, const std::array<uint32_t, 4> &key,
Proto &out, std::string *err);
bool load_proto(const std::string &path, const std::array<uint32_t, 4> &key, Proto &out,
std::string *err = nullptr);
// --- typed views over the leading fields (rest is offset-stable per stride) ---
struct ItemRecord {
uint32_t vnum = 0;
uint32_t vnum_range = 0;
std::string name; // szName[65] @ 8
std::string locale_name; // szLocaleName[65] @ 73
uint8_t type = 0; // @ 138
uint8_t sub_type = 0; // @ 139
uint8_t weight = 0; // @ 140
uint8_t size = 0; // @ 141
uint32_t wear_flags = 0; // @ 150
uint32_t buy_price = 0; // @ 158
uint32_t sell_price = 0; // @ 162
int32_t values[6] = {0}; // alValues[6] @ 191 (armor: values[3] = body shape index)
uint8_t specular = 0; // @ 234 -> PARITY §2.7 fSpecular = specular/100
};
ItemRecord parse_item(const uint8_t *rec, uint32_t stride);
struct MobRecord {
uint32_t vnum = 0;
std::string name; // szName[65] @ 4
std::string locale_name; // @ 69
uint8_t type = 0; // @ 134
uint8_t rank = 0; // @ 135
uint8_t battle_type = 0; // @ 136
uint8_t level = 0; // @ 137
uint8_t size = 0; // @ 138
};
MobRecord parse_mob(const uint8_t *rec, uint32_t stride);
} // namespace mtproto
+106
View File
@@ -0,0 +1,106 @@
#include "proto_node.h"
#include "../asset_io.h"
#include <godot_cpp/core/class_db.hpp>
#include <string>
using namespace godot;
namespace mtgodot {
void Metin2Proto::_bind_methods() {
ClassDB::bind_method(D_METHOD("load_item_proto", "path"), &Metin2Proto::load_item_proto);
ClassDB::bind_method(D_METHOD("load_mob_proto", "path"), &Metin2Proto::load_mob_proto);
ClassDB::bind_method(D_METHOD("item", "vnum"), &Metin2Proto::item);
ClassDB::bind_method(D_METHOD("mob", "vnum"), &Metin2Proto::mob);
ClassDB::bind_method(D_METHOD("item_count"), &Metin2Proto::item_count);
ClassDB::bind_method(D_METHOD("mob_count"), &Metin2Proto::mob_count);
ClassDB::bind_method(D_METHOD("get_last_error"), &Metin2Proto::get_last_error);
}
bool Metin2Proto::load_item_proto(const String &path) {
std::string err;
PackedByteArray bytes = mtgodot::read_file(path);
std::vector<uint8_t> buf(bytes.ptr(), bytes.ptr() + bytes.size());
if (!mtproto::load_proto_bytes(buf, mtproto::ITEM_PROTO_KEY, m_item, &err)) {
last_error = String(err.c_str());
return false;
}
m_item_ix.clear();
m_item_ix.reserve(m_item.elements);
for (uint32_t i = 0; i < m_item.elements; ++i) {
mtproto::ItemRecord r = mtproto::parse_item(m_item.record(i), m_item.stride);
m_item_ix[r.vnum] = i;
}
last_error = "";
return true;
}
bool Metin2Proto::load_mob_proto(const String &path) {
std::string err;
PackedByteArray bytes = mtgodot::read_file(path);
std::vector<uint8_t> buf(bytes.ptr(), bytes.ptr() + bytes.size());
if (!mtproto::load_proto_bytes(buf, mtproto::MOB_PROTO_KEY, m_mob, &err)) {
last_error = String(err.c_str());
return false;
}
m_mob_ix.clear();
m_mob_ix.reserve(m_mob.elements);
for (uint32_t i = 0; i < m_mob.elements; ++i) {
mtproto::MobRecord r = mtproto::parse_mob(m_mob.record(i), m_mob.stride);
m_mob_ix[r.vnum] = i;
}
last_error = "";
return true;
}
Dictionary Metin2Proto::item(int vnum) const {
Dictionary d;
auto it = m_item_ix.find((uint32_t)vnum);
if (it == m_item_ix.end() || m_item.record(it->second) == nullptr) {
return d;
}
mtproto::ItemRecord r = mtproto::parse_item(m_item.record(it->second), m_item.stride);
d["vnum"] = (int)r.vnum;
d["vnum_range"] = (int)r.vnum_range;
d["name"] = String::utf8(r.name.c_str());
d["locale_name"] = String::utf8(r.locale_name.c_str());
d["type"] = (int)r.type;
d["sub_type"] = (int)r.sub_type;
d["weight"] = (int)r.weight;
d["size"] = (int)r.size;
d["wear_flags"] = (int)r.wear_flags;
d["buy_price"] = (int)r.buy_price;
d["sell_price"] = (int)r.sell_price;
{
Array vals;
for (int i = 0; i < 6; ++i) {
vals.push_back((int)r.values[i]);
}
d["values"] = vals; // armor: values[3] = body shape index for the race .msm
}
d["specular"] = (int)r.specular;
return d;
}
Dictionary Metin2Proto::mob(int vnum) const {
Dictionary d;
auto it = m_mob_ix.find((uint32_t)vnum);
if (it == m_mob_ix.end() || m_mob.record(it->second) == nullptr) {
return d;
}
mtproto::MobRecord r = mtproto::parse_mob(m_mob.record(it->second), m_mob.stride);
d["vnum"] = (int)r.vnum;
d["name"] = String::utf8(r.name.c_str());
d["locale_name"] = String::utf8(r.locale_name.c_str());
d["type"] = (int)r.type;
d["rank"] = (int)r.rank;
d["battle_type"] = (int)r.battle_type;
d["level"] = (int)r.level;
d["size"] = (int)r.size;
return d;
}
} // namespace mtgodot
+48
View File
@@ -0,0 +1,48 @@
#pragma once
// Metin2Proto — GDExtension node exposing item_proto / mob_proto to GDScript.
//
// var proto = Metin2Proto.new()
// proto.load_item_proto("<assets>/locale/locale/en/item_proto")
// var d := proto.item(19) # { vnum, name, locale_name, type, sub_type, ... }
//
// Used by the P2 inventory/equipment windows for names / types / tooltips and by
// the equip->model path.
#include <godot_cpp/classes/node.hpp>
#include <godot_cpp/variant/array.hpp>
#include <godot_cpp/variant/dictionary.hpp>
#include <godot_cpp/variant/string.hpp>
#include <cstdint>
#include <unordered_map>
#include "proto.h"
namespace mtgodot {
class Metin2Proto : public godot::Node {
GDCLASS(Metin2Proto, godot::Node)
public:
bool load_item_proto(const godot::String &path);
bool load_mob_proto(const godot::String &path);
godot::Dictionary item(int vnum) const;
godot::Dictionary mob(int vnum) const;
int item_count() const { return (int)m_item.elements; }
int mob_count() const { return (int)m_mob.elements; }
godot::String get_last_error() const { return last_error; }
protected:
static void _bind_methods();
private:
mtproto::Proto m_item;
mtproto::Proto m_mob;
std::unordered_map<uint32_t, uint32_t> m_item_ix; // vnum -> record index
std::unordered_map<uint32_t, uint32_t> m_mob_ix;
godot::String last_error;
};
} // namespace mtgodot
+36 -1
View File
@@ -4,21 +4,56 @@
#include <godot_cpp/core/defs.hpp>
#include <godot_cpp/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 "proto/proto_node.h"
#include "static_object.h"
#include "terrain_splat.h"
#include "tree_placeholder.h"
#include "water_builder.h"
#include <m2_tokvec.h> // fmt::set_file_reader
#include <string>
using namespace godot;
// 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);
}
void uninitialize_mtgodot_module(ModuleInitializationLevel p_level) {
(void)p_level;
if (p_level == MODULE_INITIALIZATION_LEVEL_SCENE) {
fmt::set_file_reader(nullptr);
mtgodot::cleanup_material_shaders();
mtgodot::cleanup_terrain_shader();
mtgodot::cleanup_tree_shader();
mtgodot::cleanup_static_object_cache();
mtgodot::cleanup_water_shader();
}
}
extern "C" {
+155
View File
@@ -0,0 +1,155 @@
#include "static_object.h"
#include "asset_io.h"
#include "dxt.h"
#include "gr2_bridge.h"
#include "texture_util.h"
#include <godot_cpp/classes/image.hpp>
#include <godot_cpp/classes/image_texture.hpp>
#include <godot_cpp/classes/standard_material3d.hpp>
#include <godot_cpp/variant/packed_byte_array.hpp>
#include <godot_cpp/variant/utility_functions.hpp>
#include <asset_resolver.h>
#include <gr2/gr2.h>
#include <unordered_map>
using namespace godot;
namespace mtgodot {
namespace {
std::unordered_map<std::string, Ref<ImageTexture>> g_dds_cache;
// Building albedo (sRGB colour) -> mobile-ASTC-eligible via make_color_texture
// (no-op on desktop). mipmaps=false keeps the desktop result byte-identical to
// the old path (buildings had no mip chain). §F4.
Ref<ImageTexture> decode_dds_cached(const std::string &real_path) {
auto &cache = g_dds_cache;
auto it = cache.find(real_path);
if (it != cache.end())
return it->second;
Ref<ImageTexture> tex;
mtgodot::Image d = mtgodot::dds_from_file(godot::String(real_path.c_str()));
if (d.ok()) {
tex = mtgodot::make_color_texture(d.w, d.h, d.rgba.data(), d.rgba.size(),
/*mipmaps=*/false);
}
cache.emplace(real_path, tex);
return tex;
}
Ref<StandardMaterial3D> material_for(const std::string &tex_name, bool alpha_blend, bool two_sided,
const fmt::AssetResolver &res) {
Ref<StandardMaterial3D> mat;
mat.instantiate();
mat->set_albedo(Color(0.7f, 0.7f, 0.7f));
mat->set_roughness(1.0f);
mat->set_texture_filter(StandardMaterial3D::TEXTURE_FILTER_LINEAR_WITH_MIPMAPS_ANISOTROPIC);
mat->set_cull_mode(StandardMaterial3D::CULL_BACK);
String t = String(tex_name.c_str()).to_lower();
bool kw_alpha = t.find("leaf") != -1 || t.find("grass") != -1 || t.find("fence") != -1 ||
t.find("net") != -1 || t.find("ivy") != -1 || t.find("tree") != -1 ||
t.find("branch") != -1;
if (alpha_blend) {
// EterGrnLib TYPE_BLEND_PNT:真 alpha 混合(第 2 map 作 opacity
mat->set_transparency(StandardMaterial3D::TRANSPARENCY_ALPHA);
mat->set_cull_mode(StandardMaterial3D::CULL_DISABLED);
} else if (kw_alpha) {
mat->set_transparency(StandardMaterial3D::TRANSPARENCY_ALPHA_SCISSOR);
mat->set_alpha_scissor_threshold(0.5f);
mat->set_cull_mode(StandardMaterial3D::CULL_DISABLED);
}
if (two_sided)
mat->set_cull_mode(StandardMaterial3D::CULL_DISABLED);
if (tex_name.empty())
return mat;
std::string rp = res.resolve(tex_name, nullptr);
if (rp.empty()) {
// gr2 里常是 .dds;有时资产用别的大小写 / 扩展。先只试原名。
return mat;
}
Ref<ImageTexture> tex = decode_dds_cached(rp);
if (tex.is_valid()) {
mat->set_texture(StandardMaterial3D::TEXTURE_ALBEDO, tex);
mat->set_albedo(Color(1, 1, 1));
}
return mat;
}
} // namespace
Ref<godot::ArrayMesh> get_static_mesh(const std::string &real_gr2_path,
const fmt::AssetResolver &res, StaticMeshCache &cache) {
auto it = cache.by_path.find(real_gr2_path);
if (it != cache.by_path.end())
return it->second;
Ref<godot::ArrayMesh> result; // invalid until success
gr2::LoadError err;
auto loaded = mtgodot::gr2_from_file(godot::String(real_gr2_path.c_str()), &err);
if (!loaded) {
++cache.failed;
UtilityFunctions::push_warning(String("[static] gr2 load failed: ") +
real_gr2_path.c_str() + " (" + err.message.c_str() + ")");
cache.by_path.emplace(real_gr2_path, result);
return result;
}
const gr2::FileInfo &fi = loaded->file_info();
// 贴图名 -> 渲染态(来自 libgr2 dump_materials 的名字/map 推断)
std::vector<gr2::MaterialInfo> mats = gr2::dump_materials(*loaded);
std::map<std::string, std::pair<bool, bool>> tex_state; // lower(tex) -> {alpha, two_sided}
for (const auto &m : mats) {
std::string k;
for (char c : m.diffuse_texture)
k += (char)std::tolower((unsigned char)c);
if (!k.empty())
tex_state[k] = {m.alpha_blend, m.two_sided};
}
std::vector<mtgodot::RenderPart> parts = mtgodot::build_parts(fi);
AABB bounds;
Ref<godot::ArrayMesh> mesh = mtgodot::build_mesh(fi, parts, /*flip_winding=*/false, bounds);
if (mesh.is_valid() && mesh->get_surface_count() > 0) {
for (int s = 0; s < mesh->get_surface_count() && s < (int)parts.size(); ++s) {
const mtgodot::RenderPart &rp = parts[s];
std::string tex;
if (rp.mesh >= 0 && rp.mesh < (int)fi.meshes.size()) {
const auto &mt = fi.meshes[rp.mesh].material_textures;
if (rp.mat_index >= 0 && rp.mat_index < (int)mt.size())
tex = mt[rp.mat_index];
else if (!mt.empty())
tex = mt[0];
}
bool alpha = false, two = false;
{
std::string k;
for (char c : tex)
k += (char)std::tolower((unsigned char)c);
auto f = tex_state.find(k);
if (f != tex_state.end()) {
alpha = f->second.first;
two = f->second.second;
}
}
mesh->surface_set_material(s, material_for(tex, alpha, two, res));
}
result = mesh;
++cache.loaded;
} else {
++cache.failed;
}
cache.by_path.emplace(real_gr2_path, result);
return result;
}
void cleanup_static_object_cache() { g_dds_cache.clear(); }
} // namespace mtgodot
+35
View File
@@ -0,0 +1,35 @@
#pragma once
#include <godot_cpp/classes/array_mesh.hpp>
#include <godot_cpp/classes/ref.hpp>
#include <map>
#include <string>
namespace fmt {
struct AssetResolver;
}
namespace mtgodot {
// W3 —— 静态(无骨骼)GR2 对象。用 gr2_bridge 的 build_parts/build_mesh
// 逐 surface 从 gr2 material binding 取贴图(走 AssetResolver 定位 + dxt 解码),
// 不建 Skeleton3D、不逐帧蒙皮。Building / DungeonBlock 用。SHINSOO §9-W3。
//
// SHINSOO §9-W3「Metin2StaticModel 新节点 vs 拆 Metin2Model」的决策:
// 取轻量方案 —— 一个自由函数产出带材质的共享 ArrayMesh,调用方(Metin2World
// 直接挂到 MeshInstance3D,跨实例共享同一份 mesh。
struct StaticMeshCache {
std::map<std::string, godot::Ref<godot::ArrayMesh>> by_path;
int loaded = 0, failed = 0;
};
// real_gr2_path = AssetResolver 解析后的真实路径。返回共享 ArrayMesh(含材质)。
// 失败返回 invalid Ref。
godot::Ref<godot::ArrayMesh> get_static_mesh(const std::string &real_gr2_path,
const fmt::AssetResolver &res, StaticMeshCache &cache);
// 退出时清缓存的 Ref<Image>(别拖到 __cxa_finalize)。
void cleanup_static_object_cache();
} // namespace mtgodot
+227
View File
@@ -0,0 +1,227 @@
#include "terrain_splat.h"
#include "asset_io.h"
#include "dxt.h"
#include <godot_cpp/classes/image.hpp>
#include <godot_cpp/classes/image_texture.hpp>
#include <godot_cpp/classes/shader.hpp>
#include <godot_cpp/classes/texture2d_array.hpp>
#include <godot_cpp/variant/color.hpp>
#include <godot_cpp/variant/packed_byte_array.hpp>
#include <godot_cpp/variant/typed_array.hpp>
#include <asset_resolver.h>
#include <algorithm>
#include <unordered_map>
using namespace godot;
namespace mtgodot {
// 最多 16 层:A1 单区块实测最多 12 个活动图层(`000004`/`003004`),旧的 8 层上限会静默丢层。
static const int MAX_LAYERS = 16;
namespace {
const char *SRC_TERRAIN = R"(shader_type spatial;
render_mode diffuse_lambert, specular_disabled, cull_back;
uniform sampler2DArray layers : source_color, filter_linear_mipmap_anisotropic, repeat_enable;
uniform sampler2D weights0 : filter_linear; // RGBA = layer 0..3 alpha
uniform sampler2D weights1 : filter_linear; // RGBA = layer 4..7 alpha
uniform sampler2D weights2 : filter_linear; // RGBA = layer 8..11 alpha
uniform sampler2D weights3 : filter_linear; // RGBA = layer 12..15 alpha
uniform vec4 layer_uv[16]; // xy = 每区块平铺频率 (8*Scale)zw = offset
uniform int layer_count = 0;
uniform sampler2D shadowmap : source_color, filter_linear;
uniform bool use_shadowmap = false;
void fragment() {
vec3 col = vec3(0.32, 0.30, 0.24);
vec4 w[4];
w[0] = texture(weights0, UV);
w[1] = texture(weights1, UV);
w[2] = texture(weights2, UV);
w[3] = texture(weights3, UV);
for (int i = 0; i < 16; i++) {
if (i >= layer_count) { break; }
float wi = w[i >> 2][i & 3];
if (wi <= 0.003) { continue; }
vec2 tuv = UV * layer_uv[i].xy + layer_uv[i].zw;
vec3 lc = texture(layers, vec3(tuv, float(i))).rgb;
col = mix(col, lc, wi);
}
if (use_shadowmap) {
col *= texture(shadowmap, UV).rgb;
}
ALBEDO = col;
ROUGHNESS = 1.0;
}
)";
Ref<Shader> g_terrain_shader;
Ref<Shader> terrain_shader() {
if (g_terrain_shader.is_null()) {
g_terrain_shader.instantiate();
g_terrain_shader->set_code(SRC_TERRAIN);
}
return g_terrain_shader;
}
// DDS -> RGBA8 Imageresize 到 size×size。缓存(非函数静态 —— 见 cleanup)。
std::unordered_map<std::string, Ref<godot::Image>> g_layer_cache;
Ref<godot::Image> layer_image(const std::string &real_path, int size) {
auto &cache = g_layer_cache;
std::string key = real_path + "@" + std::to_string(size);
auto it = cache.find(key);
if (it != cache.end())
return it->second;
Ref<godot::Image> out;
mtgodot::Image d = mtgodot::dds_from_file(godot::String(real_path.c_str()));
if (d.ok()) {
PackedByteArray b;
b.resize((int64_t)d.rgba.size());
std::copy(d.rgba.begin(), d.rgba.end(), b.ptrw());
out = godot::Image::create_from_data(d.w, d.h, false, godot::Image::FORMAT_RGBA8, b);
if (out.is_valid() && (d.w != size || d.h != size))
out->resize(size, size, godot::Image::INTERPOLATE_BILINEAR);
if (out.is_valid())
out->generate_mipmaps();
}
cache.emplace(key, out);
return out;
}
// SplatLayer.alpha (258²) -> 256² 的某个通道
void pack_channel(uint8_t *dst /*256*256*4*/, int ch, const std::vector<uint8_t> &alpha258) {
const int S = fmt::SPLAT_RAW_XY; // 258
for (int y = 0; y < 256; ++y)
for (int x = 0; x < 256; ++x)
dst[(y * 256 + x) * 4 + ch] = alpha258[size_t(y + 1) * S + (x + 1)];
}
Ref<ImageTexture> weight_tex(const std::vector<const fmt::SplatLayer *> &four) {
std::vector<uint8_t> buf(size_t(256) * 256 * 4, 0);
for (int c = 0; c < 4 && c < (int)four.size(); ++c)
if (four[c])
pack_channel(buf.data(), c, four[c]->alpha);
PackedByteArray b;
b.resize((int64_t)buf.size());
std::copy(buf.begin(), buf.end(), b.ptrw());
Ref<godot::Image> img =
godot::Image::create_from_data(256, 256, false, godot::Image::FORMAT_RGBA8, b);
return ImageTexture::create_from_image(img);
}
} // namespace
void cleanup_terrain_shader() {
g_terrain_shader.unref();
g_layer_cache.clear(); // 释放缓存的 Ref<Image>,别拖到 __cxa_finalize(那时引擎已析构)
}
Ref<ShaderMaterial> build_chunk_terrain_material(const fmt::SplatSet &splat,
const fmt::TextureSet &tset, const fmt::AssetResolver &res,
const String &shadowmap_path) {
// Texture2DArray 要求各 slice 同尺寸 —— 取用到的图层里的最大源边长(上限 1024),
// 只放大不缩小最大源,避免把 512² 地表贴图硬降采样(PARITY-GAP §3.4)。
int src_max = 256;
for (const auto &L : splat.layers) {
if (L.layer >= 1 && L.layer <= (int)tset.layers.size()) {
std::string rp = res.resolve(tset.layers[L.layer - 1].texture, nullptr);
if (rp.empty())
continue;
mtgodot::Image d = mtgodot::dds_from_file(godot::String(rp.c_str()));
if (d.ok())
src_max = std::max<int>(src_max, std::max<int>(d.w, d.h));
}
}
const int LSIZE = std::min(1024, src_max);
int n = std::min<int>(MAX_LAYERS, (int)splat.layers.size());
if (n == 0)
return Ref<ShaderMaterial>();
// 颜色数组
TypedArray<godot::Image> imgs;
std::vector<Color> uvparm(MAX_LAYERS, Color(40, 40, 0, 0));
Ref<godot::Image> fallback;
{
PackedByteArray b;
b.resize(LSIZE * LSIZE * 4);
for (int i = 0; i < LSIZE * LSIZE * 4; i += 4) {
b[i] = 90;
b[i + 1] = 110;
b[i + 2] = 70;
b[i + 3] = 255;
}
fallback = godot::Image::create_from_data(LSIZE, LSIZE, false, godot::Image::FORMAT_RGBA8, b);
fallback->generate_mipmaps();
}
for (int i = 0; i < n; ++i) {
const fmt::SplatLayer &L = splat.layers[i];
Ref<godot::Image> img;
if (L.layer >= 1 && L.layer <= (int)tset.layers.size()) {
const fmt::TextureLayer &tl = tset.layers[L.layer - 1];
std::string rp = res.resolve(tl.texture, nullptr);
if (!rp.empty())
img = layer_image(rp, LSIZE);
// 原客户端 TextureSet.cpp:185u' = (TexCoordBase*UScale)*vtx_cm + UOffset
// TexCoordBase = 1/(PATCH_XSIZE*CELLSCALE) = 1/3200;区块归一化 UV -> 平铺频率 = 8*Scale。
float us = tl.u_scale > 0.01f ? tl.u_scale : 1.0f;
float vs = tl.v_scale > 0.01f ? tl.v_scale : 1.0f;
uvparm[i] = Color(8.0f * us, -8.0f * vs, tl.u_offset, -tl.v_offset);
}
imgs.push_back(img.is_valid() ? img : fallback);
}
Ref<Texture2DArray> arr;
arr.instantiate();
arr->create_from_images(imgs);
// 权重贴图:ceil(n/4) 张 RGBA8(每通道一层 alpha
Ref<ImageTexture> wtex[4];
for (int g = 0; g < 4; ++g) {
std::vector<const fmt::SplatLayer *> grp(4, nullptr);
for (int k = 0; k < 4; ++k) {
int li = g * 4 + k;
if (li < n)
grp[k] = &splat.layers[li];
}
wtex[g] = weight_tex(grp);
}
Ref<ShaderMaterial> mat;
mat.instantiate();
mat->set_shader(terrain_shader());
mat->set_shader_parameter("layers", arr);
mat->set_shader_parameter("weights0", wtex[0]);
mat->set_shader_parameter("weights1", wtex[1]);
mat->set_shader_parameter("weights2", wtex[2]);
mat->set_shader_parameter("weights3", wtex[3]);
mat->set_shader_parameter("layer_count", n);
{
Array uva;
for (int i = 0; i < MAX_LAYERS; ++i)
uva.push_back(Plane(uvparm[i].r, uvparm[i].g, uvparm[i].b, uvparm[i].a));
mat->set_shader_parameter("layer_uv", uva);
}
if (!shadowmap_path.is_empty()) {
mtgodot::Image sm = mtgodot::dds_from_file(shadowmap_path);
if (sm.ok()) {
PackedByteArray b;
b.resize((int64_t)sm.rgba.size());
std::copy(sm.rgba.begin(), sm.rgba.end(), b.ptrw());
Ref<godot::Image> smi = godot::Image::create_from_data(
sm.w, sm.h, false, godot::Image::FORMAT_RGBA8, b);
mat->set_shader_parameter("shadowmap", ImageTexture::create_from_image(smi));
mat->set_shader_parameter("use_shadowmap", true);
}
}
return mat;
}
} // namespace mtgodot
+32
View File
@@ -0,0 +1,32 @@
#pragma once
#include <godot_cpp/classes/image.hpp>
#include <godot_cpp/classes/ref.hpp>
#include <godot_cpp/classes/shader_material.hpp>
#include <godot_cpp/variant/string.hpp>
#include <splat.h>
#include <texture_set.h>
namespace fmt {
struct AssetResolver;
}
namespace mtgodot {
// W2 —— 真正的多图层地表材质。SHINSOO §9-W2。
// - 每图层的 258→256 alpha 打进 RGBA8 权重贴图(≤2 张 = ≤8 图层)
// - 每图层的颜色 DDS 解码 + resize 256²,堆成 Texture2DArray
// - ShaderMaterial:逐图层按权重 mixUV 按 TextureLayer.u_scale/offset 平铺
// - shadowmap.dds 作为 albedo 乘法项(有则)
// 光照交给 Godot 内置 DirectionalLightW5 由 .msenv 驱动)。
godot::Ref<godot::ShaderMaterial> build_chunk_terrain_material(
const fmt::SplatSet &splat,
const fmt::TextureSet &tset,
const fmt::AssetResolver &res,
const godot::String &shadowmap_path);
// 清理 function-static 的 terrain shader(退出时调,避免 "shader never freed")。
void cleanup_terrain_shader();
} // namespace mtgodot
+76
View File
@@ -0,0 +1,76 @@
#include "texture_util.h"
#include <cstdlib>
#include <cstring>
#include <godot_cpp/classes/os.hpp>
#include <godot_cpp/core/class_db.hpp>
#include <godot_cpp/variant/packed_byte_array.hpp>
#include <godot_cpp/variant/utility_functions.hpp>
using namespace godot;
namespace mtgodot {
namespace {
int g_state = -1; // -1 = uninit, 0 = off, 1 = on
bool g_warned = false;
void lazy_init() {
if (g_state != -1) {
return;
}
if (const char *e = std::getenv("MTGODOT_TEXCOMP")) {
g_state = (e[0] == '1') ? 1 : 0;
return;
}
// No explicit override: on for mobile targets, off for desktop (keeps the
// Phase-1 parity path byte-identical).
OS *os = OS::get_singleton();
g_state = (os && os->has_feature("mobile")) ? 1 : 0;
}
} // namespace
bool texcomp_enabled() {
lazy_init();
return g_state == 1;
}
void texcomp_set_enabled(bool on) {
g_state = on ? 1 : 0;
}
Ref<ImageTexture> make_color_texture(int w, int h, const uint8_t *rgba, size_t len,
bool mipmaps, TexUse use) {
if (w <= 0 || h <= 0 || rgba == nullptr || len < size_t(w) * size_t(h) * 4) {
return Ref<ImageTexture>();
}
PackedByteArray bytes;
bytes.resize(int64_t(w) * h * 4);
std::memcpy(bytes.ptrw(), rgba, size_t(w) * size_t(h) * 4);
Ref<Image> img = Image::create_from_data(w, h, false, Image::FORMAT_RGBA8, bytes);
if (img.is_null()) {
return Ref<ImageTexture>();
}
if (mipmaps) {
img->generate_mipmaps();
}
if (use == TexUse::COLOR && texcomp_enabled()) {
// ASTC 8x8: ~2 bpp vs 32 for RGBA8. GENERIC source hint (sRGB colour).
Error err = img->compress(Image::COMPRESS_ASTC, Image::COMPRESS_SOURCE_GENERIC,
Image::ASTC_FORMAT_8x8);
if (err != OK && !g_warned) {
g_warned = true;
UtilityFunctions::push_warning(
"mtgodot: runtime ASTC compression unavailable, using RGBA8");
}
}
return ImageTexture::create_from_image(img);
}
} // namespace mtgodot
+34
View File
@@ -0,0 +1,34 @@
// texture_util —— 统一的「RGBA8 字节 -> ImageTexture」出口,可选 GPU 压缩。
//
// 桌面默认关(与旧路径逐字节一致:create_from_data + generate_mipmaps +
// create_from_image)。移动端(OS.has_feature("mobile"))或 MTGODOT_TEXCOMP=1
// 时,对**颜色贴图**做运行时 ASTC 8x8 压缩,省 ~6–8x 显存/带宽;编码失败自动
// 回退 RGBA8。控制图 / 阴影图 / splat / 法线数据图不要走这个(用 RGBA8)。
//
// BACKLOG F4 / docs/PLATFORMS.md。
#pragma once
#include <cstddef>
#include <cstdint>
#include <godot_cpp/classes/image_texture.hpp>
#include <godot_cpp/classes/image.hpp>
#include <godot_cpp/classes/ref.hpp>
namespace mtgodot {
// 颜色贴图(sRGB 视觉内容,可压)。法线/数据图另说,暂不压。
enum class TexUse { COLOR };
// 运行时压缩开关。首次调用惰性初始化:MTGODOT_TEXCOMP 环境变量优先
// "1"/"0"),否则 OS.has_feature("mobile")。也可显式覆盖。
bool texcomp_enabled();
void texcomp_set_enabled(bool on);
// w*h*4 的 level-0 RGBA8 -> ImageTexture。mipmaps=true 时先生成 mip 链
// (ASTC 压缩前必须)。压缩仅在 texcomp_enabled() && use==COLOR 时发生。
godot::Ref<godot::ImageTexture> make_color_texture(
int w, int h, const uint8_t *rgba, size_t len, bool mipmaps,
TexUse use = TexUse::COLOR);
} // namespace mtgodot
+491
View File
@@ -0,0 +1,491 @@
#include "tree_placeholder.h"
#include "asset_io.h"
#include "dxt.h"
#include "texture_util.h"
#include <asset_resolver.h>
#include <spt.h>
#include <godot_cpp/classes/image.hpp>
#include <godot_cpp/classes/image_texture.hpp>
#include <godot_cpp/classes/shader.hpp>
#include <godot_cpp/classes/shader_material.hpp>
#include <godot_cpp/classes/standard_material3d.hpp>
#include <godot_cpp/variant/packed_byte_array.hpp>
#include <godot_cpp/variant/packed_int32_array.hpp>
#include <godot_cpp/variant/packed_vector2_array.hpp>
#include <godot_cpp/variant/packed_vector3_array.hpp>
#include <algorithm>
#include <cctype>
#include <cmath>
#include <cstdint>
#include <unordered_map>
#include <vector>
using namespace godot;
namespace mtgodot {
namespace {
constexpr float kPI = 3.14159265358979323846f;
constexpr float kTAU = 2.0f * kPI;
// SpeedTree 2 的叶片是中心点 + leaf-cluster table,在顶点 shader 中展开。
// proxy 已在 CPU 侧把叶簇展开成 card;这里只保留 alpha-test 和轻微、按实例错相的风摆。
const char *SRC_LEAF = R"(shader_type spatial;
render_mode cull_disabled, diffuse_lambert, specular_disabled, depth_prepass_alpha;
uniform sampler2D leaf_tex : source_color, filter_linear_mipmap_anisotropic;
uniform float wind_strength = 1.0;
void vertex() {
vec3 wp = (MODEL_MATRIX * vec4(0.0, 0.0, 0.0, 1.0)).xyz;
float ph = wp.x * 0.11 + wp.z * 0.13;
float h = max(VERTEX.y, 0.0);
VERTEX.x += sin(TIME * 1.3 + ph) * 0.025 * wind_strength * h;
VERTEX.z += cos(TIME * 1.05 + ph) * 0.018 * wind_strength * h;
}
void fragment() {
vec4 c = texture(leaf_tex, UV);
if (c.a < 0.38) { discard; }
ALBEDO = c.rgb;
ROUGHNESS = 1.0;
}
)";
Ref<Shader> g_leaf_shader;
Ref<ImageTexture> g_fallback_broadleaf;
Ref<ImageTexture> g_fallback_conifer;
std::unordered_map<std::string, Ref<ImageTexture>> g_tree_texture_cache;
std::unordered_map<std::string, Ref<ArrayMesh>> g_tree_mesh_cache;
Ref<Shader> leaf_shader() {
if (g_leaf_shader.is_null()) {
g_leaf_shader.instantiate();
g_leaf_shader->set_code(SRC_LEAF);
}
return g_leaf_shader;
}
Ref<ImageTexture> fallback_leaf_texture(bool conifer) {
Ref<ImageTexture> &cached = conifer ? g_fallback_conifer : g_fallback_broadleaf;
if (cached.is_valid())
return cached;
const int N = 96;
PackedByteArray b;
b.resize(N * N * 4);
for (int y = 0; y < N; ++y) {
for (int x = 0; x < N; ++x) {
const float u = (x + 0.5f) / N * 2.0f - 1.0f;
const float v = (y + 0.5f) / N * 2.0f - 1.0f;
const float d = std::sqrt(u * u + v * v);
float a = 1.0f - d;
a = a <= 0 ? 0.0f : a * a * (3.0f - 2.0f * a);
const float n = 0.5f + 0.5f * std::sin(x * 0.9f) * std::sin(y * 0.7f);
const float g = conifer ? (0.28f + 0.14f * n) : (0.40f + 0.16f * n);
const float r = conifer ? (0.11f + 0.06f * n) : (0.18f + 0.10f * n);
const float bl = 0.10f + 0.06f * n;
const int o = (y * N + x) * 4;
b[o + 0] = uint8_t(std::min(255.0f, r * 255.0f));
b[o + 1] = uint8_t(std::min(255.0f, g * 255.0f));
b[o + 2] = uint8_t(std::min(255.0f, bl * 255.0f));
b[o + 3] = uint8_t(std::min(255.0f, a * 255.0f));
}
}
Ref<godot::Image> img =
godot::Image::create_from_data(N, N, false, godot::Image::FORMAT_RGBA8, b);
img->generate_mipmaps();
cached = ImageTexture::create_from_image(img);
return cached;
}
Ref<ImageTexture> load_dds_texture(const std::string &path) {
if (path.empty())
return Ref<ImageTexture>();
auto found = g_tree_texture_cache.find(path);
if (found != g_tree_texture_cache.end())
return found->second;
Ref<ImageTexture> result;
mtgodot::Image d = mtgodot::dds_from_file(godot::String(path.c_str()));
if (d.ok()) {
// Bark / leaf-composite albedo (sRGB) -> mobile ASTC via
// make_color_texture (no-op on desktop; keeps mipmaps). §F4.
result = mtgodot::make_color_texture(d.w, d.h, d.rgba.data(), d.rgba.size(),
/*mipmaps=*/true);
}
g_tree_texture_cache.emplace(path, result);
return result;
}
std::string lower(std::string s) {
std::transform(s.begin(), s.end(), s.begin(),
[](unsigned char c) { return (char)std::tolower(c); });
return s;
}
std::string basename(std::string path) {
for (char &c : path)
if (c == '\\') c = '/';
const size_t slash = path.find_last_of('/');
return slash == std::string::npos ? path : path.substr(slash + 1);
}
std::string as_dds(std::string path) {
const size_t dot = path.find_last_of('.');
if (dot != std::string::npos)
path.resize(dot);
return path + ".dds";
}
std::string resolve_sibling(const std::string &treefile, const std::string &texture,
const fmt::AssetResolver &resolver) {
if (texture.empty())
return "";
std::string parent = fmt::AssetResolver::normalize(treefile);
const size_t slash = parent.find_last_of('/');
if (slash != std::string::npos)
parent.resize(slash);
else
parent.clear();
const std::string name = as_dds(basename(texture));
return resolver.resolve(parent.empty() ? name : parent + "/" + name, nullptr);
}
struct TreeTextures {
Ref<ImageTexture> bark;
Ref<ImageTexture> composite;
std::string composite_name;
};
TreeTextures resolve_tree_textures(const std::string &treefile,
const fmt::AssetResolver &resolver) {
TreeTextures out;
const std::string spt_path = resolver.resolve(treefile, nullptr);
if (spt_path.empty())
return out;
fmt::SptInfo info;
if (!fmt::sniff_spt_file(spt_path, info))
return out;
std::string branch;
for (const std::string &ref : info.texture_refs) {
if (lower(ref).find("bark") != std::string::npos) {
branch = ref;
break;
}
}
if (branch.empty() && !info.texture_refs.empty())
branch = info.texture_refs.front();
out.bark = load_dds_texture(resolve_sibling(treefile, branch, resolver));
out.composite_name = info.composite_texture;
out.composite = load_dds_texture(
resolve_sibling(treefile, info.composite_texture, resolver));
return out;
}
struct UVRect {
float u0 = 0, v0 = 0, u1 = 1, v1 = 1;
};
std::vector<UVRect> foliage_rects(const std::string &species,
const std::string &composite, bool atlas) {
if (!atlas)
return {{0, 0, 1, 1}};
// SPT leaf-cluster UV 尚未导出;这些区域只选择各 composite atlas 中的真实叶簇,
// 不声称复原了具体树种的原始 UV。Windows exporter 接入后删除这组 proxy 布局。
const std::string s = lower(species);
const std::string c = lower(composite);
const bool fall = s.find("fall") != std::string::npos;
const bool winter = s.find("winter") != std::string::npos;
if (c.find("b1") != std::string::npos) {
if (fall) return {{0.00f, 0.05f, 0.25f, 0.25f}, {0.25f, 0.25f, 0.50f, 0.50f}};
return {{0.25f, 0.02f, 0.50f, 0.23f}, {0.25f, 0.18f, 0.50f, 0.36f},
{0.00f, 0.27f, 0.27f, 0.49f}};
}
if (c.find("b2") != std::string::npos) {
if (fall) return {{0.00f, 0.00f, 0.25f, 0.25f}, {0.25f, 0.25f, 0.50f, 0.50f}};
return {{0.50f, 0.38f, 0.75f, 0.63f}, {0.50f, 0.63f, 0.75f, 0.88f},
{0.00f, 0.38f, 0.25f, 0.62f}};
}
if (c.find("b3") != std::string::npos) {
if (fall) return {{0.25f, 0.25f, 0.50f, 0.50f}};
return {{0.00f, 0.25f, 0.25f, 0.50f}, {0.00f, 0.50f, 0.25f, 0.75f},
{0.25f, 0.50f, 0.50f, 0.75f}};
}
if (c.find("n1") != std::string::npos) {
if (winter) return {{0.00f, 0.36f, 0.50f, 0.58f}, {0.25f, 0.55f, 0.52f, 0.75f}};
return {{0.00f, 0.72f, 0.28f, 0.96f}, {0.25f, 0.74f, 0.53f, 0.97f}};
}
if (c.find("n2") != std::string::npos) {
return {{0.00f, 0.48f, 0.27f, 0.75f}, {0.26f, 0.73f, 0.58f, 1.00f},
{0.75f, 0.48f, 1.00f, 0.80f}};
}
return {{0, 0, 1, 1}};
}
struct Buf {
PackedVector3Array v, n;
PackedVector2Array uv;
PackedInt32Array idx;
void quad(const Vector3 &a, const Vector3 &b, const Vector3 &c, const Vector3 &d,
const UVRect &r, bool flip_u = false) {
const int base = v.size();
const Vector3 nn = (b - a).cross(d - a).normalized();
v.push_back(a);
v.push_back(b);
v.push_back(c);
v.push_back(d);
for (int i = 0; i < 4; ++i)
n.push_back(nn);
const float l = flip_u ? r.u1 : r.u0;
const float rr = flip_u ? r.u0 : r.u1;
uv.push_back(Vector2(l, r.v1));
uv.push_back(Vector2(rr, r.v1));
uv.push_back(Vector2(rr, r.v0));
uv.push_back(Vector2(l, r.v0));
idx.push_back(base);
idx.push_back(base + 1);
idx.push_back(base + 2);
idx.push_back(base);
idx.push_back(base + 2);
idx.push_back(base + 3);
}
void tube_quad(const Vector3 &b0, const Vector3 &b1, const Vector3 &t1,
const Vector3 &t0, const Vector3 &n0, const Vector3 &n1,
float u0, float u1, float v0, float v1) {
const int base = v.size();
v.push_back(b0);
v.push_back(b1);
v.push_back(t1);
v.push_back(t0);
n.push_back(n0);
n.push_back(n1);
n.push_back(n1);
n.push_back(n0);
uv.push_back(Vector2(u0, v0));
uv.push_back(Vector2(u1, v0));
uv.push_back(Vector2(u1, v1));
uv.push_back(Vector2(u0, v1));
idx.push_back(base);
idx.push_back(base + 2);
idx.push_back(base + 1);
idx.push_back(base);
idx.push_back(base + 3);
idx.push_back(base + 2);
}
Array arrays() const {
Array a;
a.resize(Mesh::ARRAY_MAX);
a[Mesh::ARRAY_VERTEX] = v;
a[Mesh::ARRAY_NORMAL] = n;
a[Mesh::ARRAY_TEX_UV] = uv;
a[Mesh::ARRAY_INDEX] = idx;
return a;
}
};
void tube(Buf &m, const Vector3 &from, const Vector3 &to,
float r0, float r1, int seg, float bark_repeat = 1.0f) {
const Vector3 axis = (to - from).normalized();
if (axis.length_squared() < 0.5f)
return;
const Vector3 helper = std::fabs(axis.y) > 0.9f ? Vector3(1, 0, 0) : Vector3(0, 1, 0);
const Vector3 u = axis.cross(helper).normalized();
const Vector3 w = axis.cross(u).normalized();
for (int i = 0; i < seg; ++i) {
const float a0 = float(i) / seg * kTAU;
const float a1 = float(i + 1) / seg * kTAU;
const Vector3 n0 = u * std::cos(a0) + w * std::sin(a0);
const Vector3 n1 = u * std::cos(a1) + w * std::sin(a1);
m.tube_quad(from + n0 * r0, from + n1 * r0, to + n1 * r1, to + n0 * r1,
n0, n1, float(i) / seg, float(i + 1) / seg, bark_repeat, 0.0f);
}
}
uint32_t hash32(uint32_t s) {
s ^= s >> 16;
s *= 0x7feb352dU;
s ^= s >> 15;
s *= 0x846ca68bU;
s ^= s >> 16;
return s;
}
float hash01(uint32_t s) {
return float(hash32(s) & 0x00FFFFFFU) / float(0x01000000U);
}
uint32_t species_seed(const std::string &s) {
uint32_t h = 2166136261U;
for (unsigned char c : s) {
h ^= c;
h *= 16777619U;
}
return h;
}
bool species_is_palm(const std::string &hint) {
const std::string h = lower(hint);
static const char *kw[] = {"palm", "banana", "aloe", "fern", "joshua"};
for (const char *k : kw)
if (h.find(k) != std::string::npos)
return true;
return false;
}
void add_branches(Buf &wood, const std::string &species, float H, bool conifer, bool palm) {
const float trunk_top = H * (palm ? 0.82f : (conifer ? 0.90f : 0.76f));
const float trunk_r = H * (palm ? 0.028f : 0.035f);
tube(wood, Vector3(0, 0, 0), Vector3(0, trunk_top, 0),
trunk_r * 1.35f, trunk_r * 0.42f, 9, H * 0.22f);
if (palm)
return;
const int count = conifer ? 9 : 8;
const uint32_t seed = species_seed(species);
for (int i = 0; i < count; ++i) {
const float f = (i + 1.0f) / (count + 1.0f);
const float y = H * (conifer ? (0.28f + f * 0.52f) : (0.32f + f * 0.34f));
const float angle = kTAU * (f * 1.6180339f + hash01(seed + i * 17U));
const float len = H * (conifer ? (0.24f * (1.0f - f * 0.55f)) :
(0.18f + 0.08f * hash01(seed + i * 29U)));
const Vector3 from(0, y, 0);
const Vector3 to(std::cos(angle) * len,
y + H * (conifer ? 0.06f : (0.10f + 0.06f * hash01(seed + i * 31U))),
std::sin(angle) * len);
tube(wood, from, to, trunk_r * (0.55f - 0.20f * f), trunk_r * 0.12f, 6,
H * 0.08f);
if (!conifer && (i % 2 == 0)) {
const float side = angle + (hash01(seed + i * 37U) > 0.5f ? 0.65f : -0.65f);
const Vector3 tip = to + Vector3(std::cos(side), 0.65f, std::sin(side)) * (len * 0.42f);
tube(wood, to, tip, trunk_r * 0.16f, trunk_r * 0.05f, 5, H * 0.04f);
}
}
}
void add_leaf_cards(Buf &leaves, const std::string &species, float H,
bool conifer, bool palm, const std::vector<UVRect> &rects) {
const uint32_t seed = species_seed(species);
const int count = palm ? 16 : (conifer ? 24 : 24);
for (int i = 0; i < count; ++i) {
const float a = kTAU * (float(i) * 0.6180339f + hash01(seed + i * 101U) * 0.15f);
Vector3 center;
float width = 1.0f, height = 1.0f;
if (palm) {
const float radial = H * (0.10f + 0.18f * hash01(seed + i * 103U));
center = Vector3(std::cos(a) * radial, H * (0.78f + 0.12f * hash01(seed + i * 107U)),
std::sin(a) * radial);
width = H * 0.32f;
height = H * 0.18f;
} else if (conifer) {
const float yf = 0.30f + 0.62f * (float(i) + 0.5f) / count;
const float radial = H * 0.23f * (1.0f - yf * 0.70f) *
(0.35f + 0.65f * hash01(seed + i * 109U));
center = Vector3(std::cos(a) * radial, H * yf, std::sin(a) * radial);
width = H * (0.18f + 0.10f * (1.0f - yf));
height = H * 0.18f;
} else {
const float yf = hash01(seed + i * 109U);
const float yn = yf * 2.0f - 1.0f;
const float radial = H * 0.34f * std::sqrt(std::max(0.05f, 1.0f - yn * yn)) *
(0.25f + 0.75f * std::sqrt(hash01(seed + i * 113U)));
center = Vector3(std::cos(a) * radial, H * (0.58f + yf * 0.34f),
std::sin(a) * radial);
width = H * (0.23f + 0.10f * hash01(seed + i * 127U));
height = H * (0.15f + 0.08f * hash01(seed + i * 131U));
}
const Vector3 right(std::cos(a + kPI * 0.5f), 0, std::sin(a + kPI * 0.5f));
const Vector3 up(0, 1, 0);
const UVRect &uv = rects[size_t(i) % rects.size()];
auto card = [&](const Vector3 &r, bool flip) {
leaves.quad(center - r * (width * 0.5f) - up * (height * 0.5f),
center + r * (width * 0.5f) - up * (height * 0.5f),
center + r * (width * 0.5f) + up * (height * 0.5f),
center - r * (width * 0.5f) + up * (height * 0.5f), uv, flip);
};
card(right, (i & 1) != 0);
// 原 SpeedTree leaf cluster 始终面向相机;静态 proxy 用交叉 card 保证任意视角
// 都不会只看到一条边。离线 exporter 接入后由真实 leaf table 替代。
const Vector3 crossed(std::cos(a), 0, std::sin(a));
card(crossed, (i & 1) == 0);
}
}
Ref<ArrayMesh> build_proxy_impl(const std::string &species, float height_m,
const TreeTextures &textures) {
const String hint(species.c_str());
const bool conifer = species_is_conifer(hint);
const bool palm = species_is_palm(species);
const float H = std::max(2.0f, height_m);
const bool atlas = textures.composite.is_valid();
const std::vector<UVRect> rects = foliage_rects(species, textures.composite_name, atlas);
Buf wood, leaves;
add_branches(wood, species, H, conifer, palm);
add_leaf_cards(leaves, species, H, conifer, palm, rects);
Ref<ArrayMesh> mesh;
mesh.instantiate();
mesh->add_surface_from_arrays(Mesh::PRIMITIVE_TRIANGLES, wood.arrays());
mesh->add_surface_from_arrays(Mesh::PRIMITIVE_TRIANGLES, leaves.arrays());
Ref<StandardMaterial3D> bark;
bark.instantiate();
bark->set_albedo(textures.bark.is_valid() ? Color(1, 1, 1) : Color(0.30f, 0.21f, 0.13f));
bark->set_roughness(1.0f);
bark->set_texture_filter(StandardMaterial3D::TEXTURE_FILTER_LINEAR_WITH_MIPMAPS_ANISOTROPIC);
if (textures.bark.is_valid())
bark->set_texture(StandardMaterial3D::TEXTURE_ALBEDO, textures.bark);
mesh->surface_set_material(0, bark);
Ref<ShaderMaterial> leaf;
leaf.instantiate();
leaf->set_shader(leaf_shader());
leaf->set_shader_parameter("leaf_tex",
textures.composite.is_valid() ? textures.composite : fallback_leaf_texture(conifer));
leaf->set_shader_parameter("wind_strength", 1.0f);
mesh->surface_set_material(1, leaf);
return mesh;
}
} // namespace
void cleanup_tree_shader() {
g_tree_mesh_cache.clear();
g_tree_texture_cache.clear();
g_fallback_broadleaf.unref();
g_fallback_conifer.unref();
g_leaf_shader.unref();
}
bool species_is_conifer(const String &hint) {
const String h = hint.to_lower();
static const char *kw[] = {"cedar", "cypress", "pine", "fir", "spruce", "conifer", "juniper",
"christmastree"};
for (const char *k : kw)
if (h.find(k) != -1)
return true;
return false;
}
Ref<ArrayMesh> build_placeholder_tree(const String &species_hint, float height_m) {
TreeTextures empty;
return build_proxy_impl(std::string(species_hint.utf8().get_data()), height_m, empty);
}
Ref<ArrayMesh> get_tree_proxy_mesh(const std::string &treefile,
const fmt::AssetResolver &resolver, float height_m) {
const std::string key = resolver.assets_root + "|" + fmt::AssetResolver::normalize(treefile) +
"#" + std::to_string(height_m);
auto found = g_tree_mesh_cache.find(key);
if (found != g_tree_mesh_cache.end())
return found->second;
const TreeTextures textures = resolve_tree_textures(treefile, resolver);
Ref<ArrayMesh> mesh = build_proxy_impl(treefile, height_m, textures);
g_tree_mesh_cache.emplace(key, mesh);
return mesh;
}
} // namespace mtgodot
+34
View File
@@ -0,0 +1,34 @@
#pragma once
#include <godot_cpp/classes/array_mesh.hpp>
#include <godot_cpp/classes/ref.hpp>
#include <godot_cpp/variant/string.hpp>
#include <string>
namespace fmt {
struct AssetResolver;
}
// W4/R2 —— `.spt` 几何尚未跨平台读取(见 formats/spt.h),运行时使用 tree proxy
// 确定性枝干 + 多组交叉叶簇,并优先采用 .spt 指向的真实 bark/composite DDS。
// 一份共享 mesh / treefile,逐实例只由 MultiMesh 承载原 AreaData 位置。
namespace mtgodot {
// species_hint = treefile 名(判针叶/阔叶)。height_m ≈ 期望树高(米)。
godot::Ref<godot::ArrayMesh> build_placeholder_tree(const godot::String &species_hint,
float height_m = 12.0f);
// R2 tree proxy:保留无专有运行时的限制,但从 .spt 嗅探真实树皮和 composite atlas
// 用确定性的枝干/叶簇 mesh 近似 SpeedTree 的 branch/frond/leaf 分层。结果按 treefile 缓存,
// 可安全用于每树种一个 MultiMesh。若资源解析失败,自动退回纯程序化材质。
godot::Ref<godot::ArrayMesh> get_tree_proxy_mesh(const std::string &treefile,
const fmt::AssetResolver &resolver, float height_m = 12.0f);
// 从 treefile 名猜是否针叶(cedar / cypress / pine / fir / spruce…)。
bool species_is_conifer(const godot::String &species_hint);
// 退出时清 shader、DDS 和 treefile->mesh 缓存。
void cleanup_tree_shader();
} // namespace mtgodot
+199
View File
@@ -0,0 +1,199 @@
#include "water_builder.h"
#include "asset_io.h"
#include "dxt.h"
#include <godot_cpp/classes/image.hpp>
#include <godot_cpp/classes/shader.hpp>
#include <godot_cpp/classes/shader_material.hpp>
#include <godot_cpp/classes/texture2d_array.hpp>
#include <godot_cpp/variant/packed_byte_array.hpp>
#include <godot_cpp/variant/packed_color_array.hpp>
#include <godot_cpp/variant/packed_int32_array.hpp>
#include <godot_cpp/variant/packed_vector2_array.hpp>
#include <godot_cpp/variant/packed_vector3_array.hpp>
#include <godot_cpp/variant/typed_array.hpp>
#include <asset_resolver.h>
#include <m2_coord.h>
#include <terrain_mesh.h> // terrain_height_at
#include <algorithm>
using namespace godot;
namespace mtgodot {
namespace {
// 30 帧序列 + 逐顶点水深 alpha(顶点 COLOR.a) + 轻微高度浮动。
// UV = 世界米;平铺频率在 shader 里按 1/(CELLSCALE*4 cm) = 1/8m。
const char *SRC_WATER = R"(shader_type spatial;
render_mode blend_mix, cull_disabled, depth_draw_never, diffuse_lambert, specular_schlick_ggx;
uniform sampler2DArray frames : source_color, filter_linear_mipmap, repeat_enable;
uniform vec3 tint : source_color = vec3(0.10, 0.22, 0.26);
uniform float uv_per_meter = 0.125; // 1/8m= 原客户端 1/(CELLSCALE*4)
uniform float bob_amp = 0.06; // 高度浮动幅度(米),近似 MapOutdoorWater 0..-15cm
void vertex() {
VERTEX.y += sin(TIME * 0.6 + VERTEX.x * 0.01 + VERTEX.z * 0.013) * bob_amp;
}
void fragment() {
int f = int(mod(TIME * 1000.0 / 70.0, 30.0)); // 70ms/帧
vec2 uv = UV * uv_per_meter;
vec3 tex = texture(frames, vec3(uv, float(f))).rgb;
float fres = pow(1.0 - clamp(dot(normalize(VIEW), NORMAL), 0.0, 1.0), 3.0);
ALBEDO = mix(tint, tex, 0.6) + fres * 0.15;
ALPHA = clamp(COLOR.a + fres * 0.25, 0.12, 0.95);
ROUGHNESS = 0.08;
METALLIC = 0.0;
SPECULAR = 0.6;
}
)";
Ref<Shader> g_water_shader;
Ref<ShaderMaterial> g_water_mat; // 30 帧数组只建一次
Ref<ShaderMaterial> water_material(const fmt::AssetResolver &res) {
if (g_water_mat.is_valid())
return g_water_mat;
if (g_water_shader.is_null()) {
g_water_shader.instantiate();
g_water_shader->set_code(SRC_WATER);
}
Ref<ShaderMaterial> m;
m.instantiate();
m->set_shader(g_water_shader);
// special/water/01..30.dds
TypedArray<godot::Image> imgs;
int W = 0, H = 0;
for (int i = 1; i <= 30; ++i) {
char nm[64];
std::snprintf(nm, sizeof(nm), "d:/ymir work/special/water/%02d.dds", i);
std::string rp = res.resolve(nm, nullptr);
mtgodot::Image d = rp.empty() ? mtgodot::Image{} : mtgodot::dds_from_file(godot::String(rp.c_str()));
if (!d.ok())
break;
if (W == 0) {
W = d.w;
H = d.h;
}
PackedByteArray b;
b.resize((int64_t)d.rgba.size());
std::copy(d.rgba.begin(), d.rgba.end(), b.ptrw());
Ref<godot::Image> img =
godot::Image::create_from_data(d.w, d.h, false, godot::Image::FORMAT_RGBA8, b);
if (img.is_valid() && (d.w != W || d.h != H))
img->resize(W, H, godot::Image::INTERPOLATE_BILINEAR);
if (img.is_valid())
img->generate_mipmaps();
imgs.push_back(img);
}
if (imgs.size() == 30) {
Ref<Texture2DArray> arr;
arr.instantiate();
arr->create_from_images(imgs);
m->set_shader_parameter("frames", arr);
}
g_water_mat = m;
return m;
}
} // namespace
void cleanup_water_shader() {
g_water_shader.unref();
g_water_mat.unref();
}
std::vector<WaterPiece> build_chunk_water(const fmt::WaterMap &wm, const fmt::HeightMap &hm,
int tile_x, int tile_y, double height_scale, const fmt::AssetResolver &res) {
std::vector<WaterPiece> out;
if (wm.layer_count == 0 || wm.ids.size() != size_t(fmt::WATERMAP_XY) * fmt::WATERMAP_XY)
return out;
const int W = fmt::WATERMAP_XY; // 128
const double CELL_M = double(fmt::m2coord::CELLSCALE) * fmt::m2coord::CM_TO_M; // 2m/texel
const double X0 = double(tile_x) * fmt::m2coord::CHUNK_CM * fmt::m2coord::CM_TO_M;
const double Z0 = double(tile_y) * fmt::m2coord::CHUNK_CM * fmt::m2coord::CM_TO_M;
auto depth_alpha = [&](int tex_x, int tex_y, double water_h_cm) -> float {
// 水 texel (tex_x,tex_y) 对应的区块本地 cmtexel = 1 格 = CELLSCALE
double lx = tex_x * double(fmt::m2coord::CELLSCALE);
double ly = tex_y * double(fmt::m2coord::CELLSCALE);
double th = fmt::terrain_height_at(hm, lx, ly, height_scale); // cm
double depth_cm = water_h_cm - th;
float a = float(depth_cm / 60.0); // 60cm 深 -> 接近不透明
return a < 0.12f ? 0.12f : (a > 0.9f ? 0.9f : a);
};
for (int layer = 0; layer < wm.layer_count; ++layer) {
double h_cm = double(layer < (int)wm.heights.size() ? wm.heights[layer] : 0) * height_scale;
float gy = float(h_cm * fmt::m2coord::CM_TO_M);
PackedVector3Array v;
PackedVector3Array n;
PackedVector2Array uv;
PackedColorArray col;
PackedInt32Array idx;
for (int y = 0; y < W; ++y) {
int x = 0;
while (x < W) {
if (wm.ids[y * W + x] != layer) {
++x;
continue;
}
int xs = x;
while (x < W && wm.ids[y * W + x] == layer)
++x;
double x0 = X0 + xs * CELL_M, x1 = X0 + x * CELL_M;
double z0 = Z0 + y * CELL_M, z1 = Z0 + (y + 1) * CELL_M;
int base = v.size();
v.push_back(Vector3(x0, gy, z0));
v.push_back(Vector3(x1, gy, z0));
v.push_back(Vector3(x1, gy, z1));
v.push_back(Vector3(x0, gy, z1));
for (int k = 0; k < 4; ++k)
n.push_back(Vector3(0, 1, 0));
uv.push_back(Vector2(float(x0), float(z0)));
uv.push_back(Vector2(float(x1), float(z0)));
uv.push_back(Vector2(float(x1), float(z1)));
uv.push_back(Vector2(float(x0), float(z1)));
float aLL = depth_alpha(xs, y, h_cm), aLR = depth_alpha(x, y, h_cm);
float aUR = depth_alpha(x, y + 1, h_cm), aUL = depth_alpha(xs, y + 1, h_cm);
col.push_back(Color(1, 1, 1, aLL));
col.push_back(Color(1, 1, 1, aLR));
col.push_back(Color(1, 1, 1, aUR));
col.push_back(Color(1, 1, 1, aUL));
idx.push_back(base);
idx.push_back(base + 2);
idx.push_back(base + 1);
idx.push_back(base);
idx.push_back(base + 3);
idx.push_back(base + 2);
}
}
if (v.is_empty())
continue;
Array arr;
arr.resize(Mesh::ARRAY_MAX);
arr[Mesh::ARRAY_VERTEX] = v;
arr[Mesh::ARRAY_NORMAL] = n;
arr[Mesh::ARRAY_TEX_UV] = uv;
arr[Mesh::ARRAY_COLOR] = col;
arr[Mesh::ARRAY_INDEX] = idx;
Ref<godot::ArrayMesh> mesh;
mesh.instantiate();
mesh->add_surface_from_arrays(Mesh::PRIMITIVE_TRIANGLES, arr);
mesh->surface_set_material(0, water_material(res));
out.push_back({mesh, gy});
}
return out;
}
} // namespace mtgodot
+30
View File
@@ -0,0 +1,30 @@
#pragma once
#include <godot_cpp/classes/array_mesh.hpp>
#include <godot_cpp/classes/ref.hpp>
#include <terrain_files.h>
#include <vector>
namespace fmt {
struct AssetResolver;
}
// W7 / PARITY §4 —— water.wtr -> 每层水面网格(texel 掩膜)+ 共享水材质。
// 水材质用原客户端资产:`special/water/01..30.dds` 30 帧序列(`MapOutdoorWater.cpp:14/43`
// 70ms/帧),UV 平铺频率 = 1/(CELLSCALE*4)(每 4 格一循环),逐顶点水深 alpha,轻微高度浮动。
namespace mtgodot {
struct WaterPiece {
godot::Ref<godot::ArrayMesh> mesh; // Godot 空间,已含区块原点
float godot_y = 0;
};
// hm/height_scale 用来算每个水面顶点下方的地形高度 -> 水深 -> alpha。
std::vector<WaterPiece> build_chunk_water(const fmt::WaterMap &wm, const fmt::HeightMap &hm,
int tile_x, int tile_y, double height_scale, const fmt::AssetResolver &res);
void cleanup_water_shader();
} // namespace mtgodot
+112
View File
@@ -0,0 +1,112 @@
// mtnet::SecureCipher round trip — exercises the full Metin2 KX handshake +
// stream cipher + AEAD session-token path offline (no server needed).
#include "../src/net/secure_cipher.h"
#include "../src/net/wire.h"
#include <cstdio>
#include <cstring>
#include <string>
#include <vector>
using mtnet::SecureCipher;
static int g_fail = 0;
#define CHECK(c, msg) \
do { \
if (!(c)) { \
std::fprintf(stderr, "FAIL: %s\n", msg); \
++g_fail; \
} \
} while (0)
int main() {
CHECK(SecureCipher::ensure_sodium_init(), "sodium_init");
// --- key exchange: server sends KEY_CHALLENGE, client answers KEY_RESPONSE ---
SecureCipher server, client;
CHECK(server.initialize(), "server initialize");
CHECK(client.initialize(), "client initialize");
uint8_t server_pk[SecureCipher::PK_SIZE];
server.get_public_key(server_pk);
uint8_t challenge[SecureCipher::CHALLENGE_SIZE];
randombytes_buf(challenge, sizeof(challenge));
CHECK(client.compute_client_keys(server_pk), "client compute keys");
uint8_t client_pk[SecureCipher::PK_SIZE];
client.get_public_key(client_pk);
CHECK(server.compute_server_keys(client_pk), "server compute keys");
uint8_t response[crypto_auth_BYTES];
client.compute_challenge_response(challenge, response);
CHECK(server.verify_challenge_response(challenge, response), "server verifies challenge response");
// tamper -> must fail
uint8_t bad = response[0] ^ 0xFF;
uint8_t bad_resp[crypto_auth_BYTES];
std::memcpy(bad_resp, response, sizeof(response));
bad_resp[0] = bad;
CHECK(!server.verify_challenge_response(challenge, bad_resp), "tampered response rejected");
// --- KEY_COMPLETE: server encrypts a session token, client decrypts it ---
uint8_t token[SecureCipher::SESSION_TOKEN_SIZE];
randombytes_buf(token, sizeof(token));
uint8_t enc[SecureCipher::SESSION_TOKEN_SIZE + SecureCipher::TAG_SIZE];
uint8_t nonce[SecureCipher::NONCE_SIZE];
CHECK(server.encrypt_token(token, sizeof(token), enc, nonce), "server encrypt token");
uint8_t dec[SecureCipher::SESSION_TOKEN_SIZE];
CHECK(client.decrypt_token(enc, sizeof(enc), nonce, dec), "client decrypt token");
CHECK(std::memcmp(token, dec, sizeof(token)) == 0, "session token round trips");
server.set_activated(true);
client.set_activated(true);
// --- stream cipher: C->S traffic in arbitrary chunks, order-sensitive ---
const std::vector<size_t> chunk_sizes = {1, 4, 60, 3, 5, 100, 7, 64, 200, 13};
std::string acc_plain, acc_recovered;
for (size_t n : chunk_sizes) {
std::vector<uint8_t> buf(n);
for (size_t i = 0; i < n; ++i) {
buf[i] = static_cast<uint8_t>((acc_plain.size() + i) * 7 + 1);
}
acc_plain.append(reinterpret_cast<char *>(buf.data()), n);
client.encrypt_in_place(buf.data(), n); // C->S encrypt
// on the wire it looks like ciphertext; server decrypts the same bytes
server.decrypt_in_place(buf.data(), n);
acc_recovered.append(reinterpret_cast<char *>(buf.data()), n);
}
CHECK(acc_plain == acc_recovered, "C->S stream recovers across chunk boundaries");
CHECK(client.tx_nonce() == acc_plain.size(), "client tx byte counter advanced");
CHECK(server.rx_nonce() == acc_plain.size(), "server rx byte counter advanced");
// --- S->C direction is independent ---
{
std::vector<uint8_t> buf(150);
for (size_t i = 0; i < buf.size(); ++i) {
buf[i] = static_cast<uint8_t>(i ^ 0x5A);
}
std::vector<uint8_t> orig = buf;
server.encrypt_in_place(buf.data(), buf.size()); // S->C
CHECK(buf != orig, "S->C ciphertext differs from plaintext");
client.decrypt_in_place(buf.data(), buf.size());
CHECK(buf == orig, "S->C stream round trips");
}
// --- wire.h struct sizes (must match the fork's #pragma pack(1) layout) ---
CHECK(sizeof(mtnet::DynHeader) == 4, "DynHeader is 4 bytes");
CHECK(sizeof(mtnet::GCKeyChallenge) == 4 + 32 + 32 + 4, "GCKeyChallenge 72 bytes");
CHECK(sizeof(mtnet::CGKeyResponse) == 4 + 32 + 32, "CGKeyResponse 68 bytes");
CHECK(sizeof(mtnet::GCKeyComplete) == 4 + 48 + 24, "GCKeyComplete 76 bytes");
CHECK(sizeof(mtnet::CGLogin3) == 4 + 31 + 17, "CGLogin3 52 bytes");
CHECK(sizeof(mtnet::GCAuthSuccess) == 4 + 4 + 1, "GCAuthSuccess 9 bytes");
if (g_fail) {
std::fprintf(stderr, "%d check(s) failed\n", g_fail);
return 1;
}
std::printf("all checks passed\n");
return 0;
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+140
View File
@@ -0,0 +1,140 @@
// MarkImageSet + the GC_MARK_* body parsers — synthetic packets in, guild-mark
// pixels out. No socket: this covers the parse/decompress/blit path that the
// MarkClient (mark_client.h) drives over the wire.
#include "../src/net/mark_image.h"
#include "../src/net/wire.h"
#include <lzo/lzo1x.h>
#include <cstdio>
#include <cstring>
#include <vector>
using namespace mtnet;
static int g_fail = 0;
#define CHECK(c, msg) \
do { \
if (!(c)) { \
std::fprintf(stderr, "FAIL: %s\n", msg); \
++g_fail; \
} \
} while (0)
static void put_u16(std::vector<uint8_t> &b, uint16_t v) {
b.push_back((uint8_t)(v & 0xFF));
b.push_back((uint8_t)(v >> 8));
}
static void put_u32(std::vector<uint8_t> &b, uint32_t v) {
for (int i = 0; i < 4; ++i) {
b.push_back((uint8_t)((v >> (8 * i)) & 0xFF));
}
}
// LZO1X-compress one 64x48 RGBA block (MARK_BLOCK_PIXELS words).
static std::vector<uint8_t> compress_block(const uint32_t *px) {
lzo_init();
static std::vector<uint8_t> wrk(LZO1X_1_MEM_COMPRESS);
std::vector<uint8_t> out(MARK_BLOCK_PIXELS * 4 + MARK_BLOCK_PIXELS + 64);
lzo_uint out_len = out.size();
int r = lzo1x_1_compress((const uint8_t *)px, MARK_BLOCK_PIXELS * 4, out.data(), &out_len,
wrk.data());
if (r != LZO_E_OK) {
std::fprintf(stderr, "FAIL: lzo1x_1_compress -> %d\n", r);
++g_fail;
}
out.resize(out_len);
return out;
}
int main() {
// Guild 4242's mark lives at mark_id 1281 -> image 1, position 1 (row 0,
// col 1): pixel origin (16, 0). Guild 7 -> mark_id 3 -> image 0, pos 3.
const uint32_t kGuildA = 4242, kMarkA = MARK_PER_IMAGE + 1; // 1281
const uint32_t kGuildB = 7, kMarkB = 3;
// --- GC_MARK_IDXLIST body: count x {u16 guild_id, u16 mark_id} ---
MarkImageSet set;
{
std::vector<uint8_t> body;
put_u16(body, (uint16_t)kGuildA);
put_u16(body, (uint16_t)kMarkA);
put_u16(body, (uint16_t)kGuildB);
put_u16(body, (uint16_t)kMarkB);
size_t n = parse_mark_idxlist(body.data(), body.size(), 2, set);
CHECK(n == 2, "idxlist: 2 entries parsed");
}
CHECK(set.has_mark(kGuildA) && set.has_mark(kGuildB), "idxlist: both guilds registered");
CHECK(set.mark_id(kGuildA) == kMarkA, "idxlist: mark id stored");
CHECK(!set.has_mark(999), "idxlist: unknown guild absent");
// needed images = {0, 1} ascending
std::vector<int> need = set.needed_images();
CHECK(need.size() == 2 && need[0] == 0 && need[1] == 1, "needed_images = {0,1}");
// rect: guild A at image 1, (16, 0); guild B at image 0, (48, 0)
MarkRect ra = set.rect_of(kGuildA);
CHECK(ra.found && ra.img_idx == 1 && ra.x == 16 && ra.y == 0 && ra.w == 16 && ra.h == 12,
"rect_of(A) = img1 (16,0) 16x12");
MarkRect rb = set.rect_of(kGuildB);
CHECK(rb.found && rb.img_idx == 0 && rb.x == 48 && rb.y == 0, "rect_of(B) = img0 (48,0)");
// --- GC_MARK_BLOCK body for image 1, block 0 (covers marks at cols 0..3) ---
// Paint guild A's 16x12 cell (origin 16,0 within the block) a solid colour.
const uint32_t kColour = 0xFF3399CCu; // AABBGGRR
{
std::vector<uint32_t> block(MARK_BLOCK_PIXELS, 0);
for (int j = 0; j < GUILD_MARK_HEIGHT; ++j) {
for (int i = 0; i < GUILD_MARK_WIDTH; ++i) {
block[(size_t)j * MARK_BLOCK_WIDTH + (16 + i)] = kColour;
}
}
std::vector<uint8_t> comp = compress_block(block.data());
std::vector<uint8_t> body;
body.push_back(0); // block_pos 0
put_u32(body, (uint32_t)comp.size());
body.insert(body.end(), comp.begin(), comp.end());
size_t applied = parse_mark_block(body.data(), body.size(), /*img_idx=*/1, /*count=*/1, set);
CHECK(applied == 1, "block: 1 block applied");
}
// guild A's mark pixels should now be the solid colour; guild B (no block
// for image 0 yet) should come back empty.
std::vector<uint32_t> pa = set.mark_pixels(kGuildA);
CHECK(pa.size() == (size_t)(GUILD_MARK_WIDTH * GUILD_MARK_HEIGHT), "mark_pixels(A): 192 words");
bool all_colour = !pa.empty();
for (uint32_t p : pa) {
all_colour = all_colour && (p == kColour);
}
CHECK(all_colour, "mark_pixels(A): every pixel is the painted colour");
CHECK(set.mark_pixels(kGuildB).empty(), "mark_pixels(B): empty (image 0 not downloaded)");
// corrupt compressed data -> apply_block fails, image untouched
{
uint8_t junk[8] = {1, 2, 3, 4, 5, 6, 7, 8};
CHECK(!set.apply_block(1, 1, junk, sizeof(junk)), "apply_block: junk rejected");
}
// out-of-range indices
CHECK(!set.apply_block(-1, 0, (const uint8_t *)"x", 1), "apply_block: bad img idx");
CHECK(!set.apply_block(0, MARK_BLOCK_TOTAL_COUNT, (const uint8_t *)"x", 1),
"apply_block: bad block pos");
// truncated block body -> nothing applied, no crash
{
std::vector<uint8_t> body;
body.push_back(2);
put_u32(body, 9999); // claims 9999 bytes that aren't there
size_t applied = parse_mark_block(body.data(), body.size(), 0, 1, set);
CHECK(applied == 0, "block: truncated body -> 0 applied");
}
if (g_fail) {
std::fprintf(stderr, "%d check(s) failed\n", g_fail);
return 1;
}
std::printf("PASS: net_mark_test\n");
return 0;
}
+143
View File
@@ -0,0 +1,143 @@
// EterPack writer -> reader round trip (no external pack file needed).
#include "../src/pack/eterpack.h"
#include "../src/pack/pack_mount.h"
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <string>
#include <vector>
using namespace mtpack;
static int g_fail = 0;
#define CHECK(c, msg) \
do { \
if (!(c)) { \
std::fprintf(stderr, "FAIL: %s\n", msg); \
++g_fail; \
} \
} while (0)
static std::vector<uint8_t> bytes(const std::string &s) {
return std::vector<uint8_t>(s.begin(), s.end());
}
static bool roundtrip(bool encrypt, const char *tag) {
std::vector<InputFile> in;
in.push_back({"d:/ymir work/pc/warrior/warrior.gr2", bytes(std::string(5000, 'A'))}); // compressible
{
std::vector<uint8_t> rnd(4096);
for (size_t i = 0; i < rnd.size(); ++i) {
rnd[i] = static_cast<uint8_t>((i * 2654435761u) >> 13);
}
in.push_back({"textureset/metin2_a1.txt", rnd});
}
in.push_back({"tiny.dat", bytes("x")});
in.push_back({"nested/deep/path/file.bin", bytes("hello \x00 world binary\xff\xfe")});
std::string path = std::string(std::getenv("TMPDIR") ? std::getenv("TMPDIR") : "/tmp") +
"/mtpack_test_" + tag + ".epk";
std::string err;
if (!write_pack(path, in, encrypt, &err)) {
std::fprintf(stderr, "write_pack(%s): %s\n", tag, err.c_str());
return false;
}
EterPack pk;
if (!pk.open(path, &err)) {
std::fprintf(stderr, "open(%s): %s\n", tag, err.c_str());
return false;
}
CHECK(pk.count() == in.size(), "entry count");
CHECK(pk.name_field() == PACK_NAME_FIELD_DEFAULT, "derived name field == default");
for (const auto &f : in) {
CHECK(pk.has(f.name), (std::string("has ") + f.name).c_str());
// case / slash insensitivity
std::string up = f.name;
for (auto &c : up) {
c = static_cast<char>(std::toupper((unsigned char)c));
}
std::replace(up.begin(), up.end(), '/', '\\');
CHECK(pk.has(up), (std::string("has (norm) ") + f.name).c_str());
std::vector<uint8_t> got;
if (!pk.read(f.name, got, &err)) {
std::fprintf(stderr, "read(%s): %s\n", f.name.c_str(), err.c_str());
++g_fail;
continue;
}
CHECK(got == f.data, (std::string("bytes match ") + f.name).c_str());
}
CHECK(!pk.read("does/not/exist", *(new std::vector<uint8_t>()), &err), "missing file -> false");
std::remove(path.c_str());
return true;
}
static std::string tmp(const char *n) {
return std::string(std::getenv("TMPDIR") ? std::getenv("TMPDIR") : "/tmp") + "/mtpack_" + n;
}
static bool mount_test() {
// pack A: base assets, "ymir work/" layout
std::vector<InputFile> a;
a.push_back({"ymir work/ui/pattern/board_base.tga", bytes("BASE-BOARD-v1")});
a.push_back({"ymir work/tree/b1_pagoda.spt", bytes(std::string(2000, 'S'))});
a.push_back({"locale/loading.png", bytes("PNGDATA")});
std::string pa = tmp("mount_a.epk");
std::string err;
if (!write_pack(pa, a, false, &err)) {
std::fprintf(stderr, "write A: %s\n", err.c_str());
return false;
}
// pack B: a patch that overrides board_base
std::vector<InputFile> b;
b.push_back({"ymir work/ui/pattern/board_base.tga", bytes("BASE-BOARD-v2-PATCHED")});
std::string pb = tmp("metin2_patch_x.epk");
if (!write_pack(pb, b, true, &err)) {
std::fprintf(stderr, "write B: %s\n", err.c_str());
return false;
}
mtpack::PackMount m;
CHECK(m.mount(pa, &err), ("mount A: " + err).c_str());
CHECK(m.mount(pb, &err), ("mount B: " + err).c_str());
CHECK(m.pack_count() == 2, "2 packs mounted");
std::vector<uint8_t> out;
// full path
CHECK(m.has("ymir work/tree/b1_pagoda.spt"), "has by full path");
CHECK(m.read("ymir work/tree/b1_pagoda.spt", out) && out.size() == 2000, "read by full path");
// virtual path with drive + backslashes + case
CHECK(m.has("D:\\YMIR WORK\\Tree\\B1_Pagoda.spt"), "has by d:\\ virtual path");
CHECK(m.read("d:/ymir work/tree/b1_pagoda.spt", out) && out.size() == 2000, "read by virtual");
// "ymir work/"-suffix keying: a vpath that omits the leading dirs
CHECK(m.read("somewhere/ymir work/ui/pattern/board_base.tga", out), "read via ymir-suffix");
// later pack (patch) wins
CHECK(std::string(out.begin(), out.end()) == "BASE-BOARD-v2-PATCHED", "patch pack overrides base");
// non-ymir path still works
CHECK(m.read("locale/loading.png", out) &&
std::string(out.begin(), out.end()) == "PNGDATA",
"non-ymir path");
CHECK(!m.has("does/not/exist"), "missing -> false");
std::remove(pa.c_str());
std::remove(pb.c_str());
return true;
}
int main() {
CHECK(roundtrip(false, "plain"), "plain roundtrip ran");
CHECK(roundtrip(true, "enc"), "encrypted roundtrip ran");
CHECK(mount_test(), "PackMount test ran");
if (g_fail) {
std::fprintf(stderr, "%d check(s) failed\n", g_fail);
return 1;
}
std::printf("all checks passed\n");
return 0;
}
+150
View File
@@ -0,0 +1,150 @@
// item_proto / mob_proto reader — against the real locale files if present.
// Set M2_ASSETS to the Metin2 assets dir, else falls back to a repo-relative
// guess; skips (passes) if the files are not found.
#include "../src/proto/proto.h"
#include <cstdio>
#include <cstdlib>
#include <string>
using namespace mtproto;
static int g_fail = 0;
#define CHECK(c, msg) \
do { \
if (!(c)) { \
std::fprintf(stderr, "FAIL: %s\n", msg); \
++g_fail; \
} \
} while (0)
static std::string assets_root() {
if (const char *e = std::getenv("M2_ASSETS")) {
return e;
}
return "../../assets"; // ctest cwd = build/extension -> <repo>/assets
}
static bool exists(const std::string &p) {
FILE *f = std::fopen(p.c_str(), "rb");
if (f) {
std::fclose(f);
return true;
}
return false;
}
int main() {
const std::string base = assets_root() + "/locale/locale/en";
const std::string ipath = base + "/item_proto";
const std::string mpath = base + "/mob_proto";
if (!exists(ipath) || !exists(mpath)) {
std::printf("skipped (no locale proto files at %s)\n", base.c_str());
return 0;
}
// --- item_proto ---
{
Proto p;
std::string err;
bool ok = load_proto(ipath, ITEM_PROTO_KEY, p, &err);
CHECK(ok, ("load item_proto: " + err).c_str());
if (ok) {
CHECK(p.fourcc == 0x5850494Du, "item fourcc MIPX");
CHECK(p.version == 1, "item version 1");
CHECK(p.stride == 236, "item stride 236 (== pack(1) sizeof TItemTable)");
CHECK(p.elements > 100, "item elements > 100");
CHECK(p.blob.size() == static_cast<size_t>(p.stride) * p.elements, "item blob size");
// every record: vnum monotonically increasing, name printable ascii
uint32_t prev = 0;
int named = 0, mono = 1;
uint8_t max_spec = 0;
for (uint32_t i = 0; i < p.elements; ++i) {
ItemRecord it = parse_item(p.record(i), p.stride);
if (it.vnum && it.vnum <= prev) {
mono = 0;
}
prev = it.vnum ? it.vnum : prev;
if (!it.name.empty()) {
++named;
}
if (it.specular > max_spec) {
max_spec = it.specular;
}
}
CHECK(mono, "item vnums non-decreasing");
CHECK(named > p.elements * 0.8, "item: >80% have a name");
std::printf("item_proto: %u items, stride %u, %d named, max bSpecular=%u\n",
p.elements, p.stride, named, max_spec);
ItemRecord first = parse_item(p.record(0), p.stride);
std::printf(" [0] vnum=%u '%s' type=%u sub=%u weight=%u\n", first.vnum,
first.name.c_str(), first.type, first.sub_type, first.weight);
CHECK(first.vnum > 0 && first.vnum < 100000, "item[0] vnum sane");
CHECK(!first.name.empty(), "item[0] has a name");
// alValues[3] = body-armor shape index (anchored by bSpecular @234).
// "Monk Plate Armour" 11200..11209 all share shape 3; specular ramps
// with the refine level (0 -> 100).
bool found_armor = false;
for (uint32_t i = 0; i < p.elements; ++i) {
ItemRecord it = parse_item(p.record(i), p.stride);
if (it.vnum == 11209) {
found_armor = true;
std::printf(" 11209 values=[%d,%d,%d,%d,%d,%d] spec=%u\n", it.values[0],
it.values[1], it.values[2], it.values[3], it.values[4], it.values[5],
it.specular);
CHECK(it.values[3] == 3, "item 11209: values[3] == shape 3");
CHECK(it.specular == 100, "item 11209 (+9): bSpecular == 100");
}
if (it.vnum == 11200) {
CHECK(it.values[3] == 3 && it.specular == 0,
"item 11200 (+0): shape 3, specular 0");
}
}
CHECK(found_armor, "item_proto contains vnum 11209");
}
}
// --- mob_proto ---
{
Proto p;
std::string err;
bool ok = load_proto(mpath, MOB_PROTO_KEY, p, &err);
CHECK(ok, ("load mob_proto: " + err).c_str());
if (ok) {
CHECK(p.fourcc == 0x54504D4Du, "mob fourcc MMPT");
CHECK(p.stride == 335, "mob stride 335 (derived from realSize/elements)");
CHECK(p.elements > 100, "mob elements > 100");
int named = 0, maxlvl = 0;
for (uint32_t i = 0; i < p.elements; ++i) {
MobRecord m = parse_mob(p.record(i), p.stride);
if (!m.name.empty()) {
++named;
}
if (m.level > maxlvl) {
maxlvl = m.level;
}
}
CHECK(named > p.elements * 0.8, "mob: >80% named");
CHECK(maxlvl > 20 && maxlvl < 256, "mob levels in a sane range");
MobRecord first = parse_mob(p.record(0), p.stride);
std::printf("mob_proto: %u mobs, stride %u, %d named, maxlvl=%d\n [0] vnum=%u '%s' "
"type=%u rank=%u level=%u\n",
p.elements, p.stride, named, maxlvl, first.vnum, first.name.c_str(), first.type,
first.rank, first.level);
CHECK(first.vnum > 0 && first.vnum < 60000, "mob[0] vnum sane");
CHECK(!first.name.empty(), "mob[0] has a name");
}
}
if (g_fail) {
std::fprintf(stderr, "%d check(s) failed\n", g_fail);
return 1;
}
std::printf("all checks passed\n");
return 0;
}
+43
View File
@@ -0,0 +1,43 @@
# extension/third_party — vendored native dependencies for the GDExtension.
#
# Phase 1 (macOS) used Homebrew for libsodium / libzstd / liblzo2. Those don't
# exist on the Android NDK or iOS SDK sysroots, so the mobile bring-up (BACKLOG
# F1/F2) needs them built from source as part of our own build. All three are
# pinned here and produce static libs that link into libmtgodot:
#
# sodium <- libsodium-cmake submodule (wraps jedisct1/libsodium)
# libzstd_static <- facebook/zstd submodule, its own build/cmake project
# minilzo <- vendored miniLZO source (LZO1X, ~4 files) — see NOTICE
#
# See docs/THIRD-PARTY.md for versions / licenses. liblzo2 (and thus miniLZO) is
# GPL: fine for this internal, non-published project — same footing as
# libgr2/src/oodle1.c — but must be swapped or re-licensed before any release.
# Everything here is archived into the SHARED libmtgodot, so it must be PIC.
set(CMAKE_POSITION_INDEPENDENT_CODE ON)
# --- libsodium ------------------------------------------------------------------
set(SODIUM_DISABLE_TESTS ON CACHE BOOL "" FORCE)
set(SODIUM_MINIMAL OFF CACHE BOOL "" FORCE)
add_subdirectory(libsodium-cmake EXCLUDE_FROM_ALL)
# The wrapper's target is `sodium`; give it a namespaced alias for consumers.
add_library(mt3p::sodium ALIAS sodium)
# --- libzstd ------------------------------------------------------------------
set(ZSTD_BUILD_PROGRAMS OFF CACHE BOOL "" FORCE)
set(ZSTD_BUILD_SHARED OFF CACHE BOOL "" FORCE)
set(ZSTD_BUILD_STATIC ON CACHE BOOL "" FORCE)
set(ZSTD_BUILD_TESTS OFF CACHE BOOL "" FORCE)
set(ZSTD_BUILD_CONTRIB OFF CACHE BOOL "" FORCE)
set(ZSTD_LEGACY_SUPPORT OFF CACHE BOOL "" FORCE)
set(ZSTD_MULTITHREAD_SUPPORT OFF CACHE BOOL "" FORCE)
add_subdirectory(zstd/build/cmake zstd-build EXCLUDE_FROM_ALL)
add_library(mt3p::zstd ALIAS libzstd_static)
# --- miniLZO ------------------------------------------------------------------
# One translation unit, an amalgamation generated from the LZO sources. Its
# public API (<lzo/lzo1x.h> via the shim header) is a strict subset of full LZO.
add_library(minilzo STATIC minilzo/minilzo.c)
target_include_directories(minilzo PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/minilzo")
set_target_properties(minilzo PROPERTIES C_STANDARD 99)
add_library(mt3p::minilzo ALIAS minilzo)
+3
View File
@@ -0,0 +1,3 @@
Authors of the LZO data compression library:
Markus F.X.J. Oberhumer. Invented, designed and implemented LZO.
+339
View File
@@ -0,0 +1,339 @@
GNU GENERAL PUBLIC LICENSE
Version 2, June 1991
Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The licenses for most software are designed to take away your
freedom to share and change it. By contrast, the GNU General Public
License is intended to guarantee your freedom to share and change free
software--to make sure the software is free for all its users. This
General Public License applies to most of the Free Software
Foundation's software and to any other program whose authors commit to
using it. (Some other Free Software Foundation software is covered by
the GNU Lesser General Public License instead.) You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
this service if you wish), that you receive source code or can get it
if you want it, that you can change the software or use pieces of it
in new free programs; and that you know you can do these things.
To protect your rights, we need to make restrictions that forbid
anyone to deny you these rights or to ask you to surrender the rights.
These restrictions translate to certain responsibilities for you if you
distribute copies of the software, or if you modify it.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must give the recipients all the rights that
you have. You must make sure that they, too, receive or can get the
source code. And you must show them these terms so they know their
rights.
We protect your rights with two steps: (1) copyright the software, and
(2) offer you this license which gives you legal permission to copy,
distribute and/or modify the software.
Also, for each author's protection and ours, we want to make certain
that everyone understands that there is no warranty for this free
software. If the software is modified by someone else and passed on, we
want its recipients to know that what they have is not the original, so
that any problems introduced by others will not reflect on the original
authors' reputations.
Finally, any free program is threatened constantly by software
patents. We wish to avoid the danger that redistributors of a free
program will individually obtain patent licenses, in effect making the
program proprietary. To prevent this, we have made it clear that any
patent must be licensed for everyone's free use or not licensed at all.
The precise terms and conditions for copying, distribution and
modification follow.
GNU GENERAL PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. This License applies to any program or other work which contains
a notice placed by the copyright holder saying it may be distributed
under the terms of this General Public License. The "Program", below,
refers to any such program or work, and a "work based on the Program"
means either the Program or any derivative work under copyright law:
that is to say, a work containing the Program or a portion of it,
either verbatim or with modifications and/or translated into another
language. (Hereinafter, translation is included without limitation in
the term "modification".) Each licensee is addressed as "you".
Activities other than copying, distribution and modification are not
covered by this License; they are outside its scope. The act of
running the Program is not restricted, and the output from the Program
is covered only if its contents constitute a work based on the
Program (independent of having been made by running the Program).
Whether that is true depends on what the Program does.
1. You may copy and distribute verbatim copies of the Program's
source code as you receive it, in any medium, provided that you
conspicuously and appropriately publish on each copy an appropriate
copyright notice and disclaimer of warranty; keep intact all the
notices that refer to this License and to the absence of any warranty;
and give any other recipients of the Program a copy of this License
along with the Program.
You may charge a fee for the physical act of transferring a copy, and
you may at your option offer warranty protection in exchange for a fee.
2. You may modify your copy or copies of the Program or any portion
of it, thus forming a work based on the Program, and copy and
distribute such modifications or work under the terms of Section 1
above, provided that you also meet all of these conditions:
a) You must cause the modified files to carry prominent notices
stating that you changed the files and the date of any change.
b) You must cause any work that you distribute or publish, that in
whole or in part contains or is derived from the Program or any
part thereof, to be licensed as a whole at no charge to all third
parties under the terms of this License.
c) If the modified program normally reads commands interactively
when run, you must cause it, when started running for such
interactive use in the most ordinary way, to print or display an
announcement including an appropriate copyright notice and a
notice that there is no warranty (or else, saying that you provide
a warranty) and that users may redistribute the program under
these conditions, and telling the user how to view a copy of this
License. (Exception: if the Program itself is interactive but
does not normally print such an announcement, your work based on
the Program is not required to print an announcement.)
These requirements apply to the modified work as a whole. If
identifiable sections of that work are not derived from the Program,
and can be reasonably considered independent and separate works in
themselves, then this License, and its terms, do not apply to those
sections when you distribute them as separate works. But when you
distribute the same sections as part of a whole which is a work based
on the Program, the distribution of the whole must be on the terms of
this License, whose permissions for other licensees extend to the
entire whole, and thus to each and every part regardless of who wrote it.
Thus, it is not the intent of this section to claim rights or contest
your rights to work written entirely by you; rather, the intent is to
exercise the right to control the distribution of derivative or
collective works based on the Program.
In addition, mere aggregation of another work not based on the Program
with the Program (or with a work based on the Program) on a volume of
a storage or distribution medium does not bring the other work under
the scope of this License.
3. You may copy and distribute the Program (or a work based on it,
under Section 2) in object code or executable form under the terms of
Sections 1 and 2 above provided that you also do one of the following:
a) Accompany it with the complete corresponding machine-readable
source code, which must be distributed under the terms of Sections
1 and 2 above on a medium customarily used for software interchange; or,
b) Accompany it with a written offer, valid for at least three
years, to give any third party, for a charge no more than your
cost of physically performing source distribution, a complete
machine-readable copy of the corresponding source code, to be
distributed under the terms of Sections 1 and 2 above on a medium
customarily used for software interchange; or,
c) Accompany it with the information you received as to the offer
to distribute corresponding source code. (This alternative is
allowed only for noncommercial distribution and only if you
received the program in object code or executable form with such
an offer, in accord with Subsection b above.)
The source code for a work means the preferred form of the work for
making modifications to it. For an executable work, complete source
code means all the source code for all modules it contains, plus any
associated interface definition files, plus the scripts used to
control compilation and installation of the executable. However, as a
special exception, the source code distributed need not include
anything that is normally distributed (in either source or binary
form) with the major components (compiler, kernel, and so on) of the
operating system on which the executable runs, unless that component
itself accompanies the executable.
If distribution of executable or object code is made by offering
access to copy from a designated place, then offering equivalent
access to copy the source code from the same place counts as
distribution of the source code, even though third parties are not
compelled to copy the source along with the object code.
4. You may not copy, modify, sublicense, or distribute the Program
except as expressly provided under this License. Any attempt
otherwise to copy, modify, sublicense or distribute the Program is
void, and will automatically terminate your rights under this License.
However, parties who have received copies, or rights, from you under
this License will not have their licenses terminated so long as such
parties remain in full compliance.
5. You are not required to accept this License, since you have not
signed it. However, nothing else grants you permission to modify or
distribute the Program or its derivative works. These actions are
prohibited by law if you do not accept this License. Therefore, by
modifying or distributing the Program (or any work based on the
Program), you indicate your acceptance of this License to do so, and
all its terms and conditions for copying, distributing or modifying
the Program or works based on it.
6. Each time you redistribute the Program (or any work based on the
Program), the recipient automatically receives a license from the
original licensor to copy, distribute or modify the Program subject to
these terms and conditions. You may not impose any further
restrictions on the recipients' exercise of the rights granted herein.
You are not responsible for enforcing compliance by third parties to
this License.
7. If, as a consequence of a court judgment or allegation of patent
infringement or for any other reason (not limited to patent issues),
conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot
distribute so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you
may not distribute the Program at all. For example, if a patent
license would not permit royalty-free redistribution of the Program by
all those who receive copies directly or indirectly through you, then
the only way you could satisfy both it and this License would be to
refrain entirely from distribution of the Program.
If any portion of this section is held invalid or unenforceable under
any particular circumstance, the balance of the section is intended to
apply and the section as a whole is intended to apply in other
circumstances.
It is not the purpose of this section to induce you to infringe any
patents or other property right claims or to contest validity of any
such claims; this section has the sole purpose of protecting the
integrity of the free software distribution system, which is
implemented by public license practices. Many people have made
generous contributions to the wide range of software distributed
through that system in reliance on consistent application of that
system; it is up to the author/donor to decide if he or she is willing
to distribute software through any other system and a licensee cannot
impose that choice.
This section is intended to make thoroughly clear what is believed to
be a consequence of the rest of this License.
8. If the distribution and/or use of the Program is restricted in
certain countries either by patents or by copyrighted interfaces, the
original copyright holder who places the Program under this License
may add an explicit geographical distribution limitation excluding
those countries, so that distribution is permitted only in or among
countries not thus excluded. In such case, this License incorporates
the limitation as if written in the body of this License.
9. The Free Software Foundation may publish revised and/or new versions
of the General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the Program
specifies a version number of this License which applies to it and "any
later version", you have the option of following the terms and conditions
either of that version or of any later version published by the Free
Software Foundation. If the Program does not specify a version number of
this License, you may choose any version ever published by the Free Software
Foundation.
10. If you wish to incorporate parts of the Program into other free
programs whose distribution conditions are different, write to the author
to ask for permission. For software which is copyrighted by the Free
Software Foundation, write to the Free Software Foundation; we sometimes
make exceptions for this. Our decision will be guided by the two goals
of preserving the free status of all derivatives of our free software and
of promoting the sharing and reuse of software generally.
NO WARRANTY
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
REPAIR OR CORRECTION.
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
POSSIBILITY OF SUCH DAMAGES.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
convey the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License along
with this program; if not, write to the Free Software Foundation, Inc.,
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
Also add information on how to contact you by electronic and paper mail.
If the program is interactive, make it output a short notice like this
when it starts in an interactive mode:
Gnomovision version 69, Copyright (C) year name of author
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, the commands you use may
be called something other than `show w' and `show c'; they could even be
mouse-clicks or menu items--whatever suits your program.
You should also get your employer (if you work as a programmer) or your
school, if any, to sign a "copyright disclaimer" for the program, if
necessary. Here is a sample; alter the names:
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
`Gnomovision' (which makes passes at compilers) written by James Hacker.
<signature of Ty Coon>, 1 April 1989
Ty Coon, President of Vice
This General Public License does not permit incorporating your program into
proprietary programs. If your program is a subroutine library, you may
consider it more useful to permit linking proprietary applications with the
library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License.
+123
View File
@@ -0,0 +1,123 @@
============================================================================
miniLZO -- mini subset of the LZO real-time data compression library
============================================================================
Author : Markus Franz Xaver Johannes Oberhumer
<markus@oberhumer.com>
http://www.oberhumer.com/opensource/lzo/
Version : 2.10
Date : 01 Mar 2017
I've created miniLZO for projects where it is inconvenient to
include (or require) the full LZO source code just because you
want to add a little bit of data compression to your application.
miniLZO implements the LZO1X-1 compressor and both the standard and
safe LZO1X decompressor. Apart from fast compression it also useful
for situations where you want to use pre-compressed data files (which
must have been compressed with LZO1X-999).
miniLZO consists of one C source file and three header files:
minilzo.c
minilzo.h, lzoconf.h, lzodefs.h
To use miniLZO just copy these files into your source directory, add
minilzo.c to your Makefile and #include minilzo.h from your program.
Note: you also must distribute this file ('README.LZO') with your project.
minilzo.o compiles to about 6 KiB (using gcc or Visual C on an i386), and
the sources are about 30 KiB when packed with zip - so there's no more
excuse that your application doesn't support data compression :-)
For more information, documentation, example programs and other support
files (like Makefiles and build scripts) please download the full LZO
package from
http://www.oberhumer.com/opensource/lzo/
Have fun,
Markus
P.S. minilzo.c is generated automatically from the LZO sources and
therefore functionality is completely identical
Appendix A: building miniLZO
----------------------------
miniLZO is written such a way that it should compile and run
out-of-the-box on most machines.
If you are running on a very unusual architecture and lzo_init() fails then
you should first recompile with '-DLZO_DEBUG' to see what causes the failure.
The most probable case is something like 'sizeof(void *) != sizeof(size_t)'.
After identifying the problem you can compile by adding some defines
like '-DSIZEOF_VOID_P=8' to your Makefile.
The best solution is (of course) using Autoconf - if your project uses
Autoconf anyway just add '-DMINILZO_HAVE_CONFIG_H' to your compiler
flags when compiling minilzo.c. See the LZO distribution for an example
how to set up configure.ac.
Appendix B: list of public functions available in miniLZO
---------------------------------------------------------
Library initialization
lzo_init()
Compression
lzo1x_1_compress()
Decompression
lzo1x_decompress()
lzo1x_decompress_safe()
Checksum functions
lzo_adler32()
Version functions
lzo_version()
lzo_version_string()
lzo_version_date()
Portable (but slow) string functions
lzo_memcmp()
lzo_memcpy()
lzo_memmove()
lzo_memset()
Appendix C: suggested macros for 'configure.ac' when using Autoconf
-------------------------------------------------------------------
Checks for typedefs and structures
AC_CHECK_TYPE(ptrdiff_t,long)
AC_TYPE_SIZE_T
AC_CHECK_SIZEOF(short)
AC_CHECK_SIZEOF(int)
AC_CHECK_SIZEOF(long)
AC_CHECK_SIZEOF(long long)
AC_CHECK_SIZEOF(__int64)
AC_CHECK_SIZEOF(void *)
AC_CHECK_SIZEOF(size_t)
AC_CHECK_SIZEOF(ptrdiff_t)
Checks for compiler characteristics
AC_C_CONST
Checks for library functions
AC_CHECK_FUNCS(memcmp memcpy memmove memset)
Appendix D: Copyright
---------------------
LZO and miniLZO are Copyright (C) 1996-2017 Markus Franz Xaver Oberhumer
All Rights Reserved.
LZO and miniLZO are distributed under the terms of the GNU General
Public License (GPL). See the file COPYING.
Special licenses for commercial and other applications which
are not willing to accept the GNU General Public License
are available by contacting the author.
+12
View File
@@ -0,0 +1,12 @@
/* Compatibility shim: the full LZO distribution exposes the LZO1X API as
* <lzo/lzo1x.h>. We vendor only miniLZO (LZO1X-1 compressor + safe/standard
* LZO1X decompressor), which is all mtproto's CLZO path needs
* (lzo_init / lzo1x_decompress_safe / LZO_E_OK / lzo_uint). This header lets
* extension/src/proto/proto.cpp keep its `#include <lzo/lzo1x.h>` unchanged.
*/
#ifndef MTGODOT_MINILZO_LZO1X_SHIM_H
#define MTGODOT_MINILZO_LZO1X_SHIM_H
#include "../minilzo.h"
#endif
+453
View File
@@ -0,0 +1,453 @@
/* lzoconf.h -- configuration of the LZO data compression library
This file is part of the LZO real-time data compression library.
Copyright (C) 1996-2017 Markus Franz Xaver Johannes Oberhumer
All Rights Reserved.
The LZO library is free software; you can redistribute it and/or
modify it under the terms of the GNU General Public License as
published by the Free Software Foundation; either version 2 of
the License, or (at your option) any later version.
The LZO library is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with the LZO library; see the file COPYING.
If not, write to the Free Software Foundation, Inc.,
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
Markus F.X.J. Oberhumer
<markus@oberhumer.com>
http://www.oberhumer.com/opensource/lzo/
*/
#ifndef __LZOCONF_H_INCLUDED
#define __LZOCONF_H_INCLUDED 1
#define LZO_VERSION 0x20a0 /* 2.10 */
#define LZO_VERSION_STRING "2.10"
#define LZO_VERSION_DATE "Mar 01 2017"
/* internal Autoconf configuration file - only used when building LZO */
#if defined(LZO_HAVE_CONFIG_H)
# include <config.h>
#endif
#include <limits.h>
#include <stddef.h>
/***********************************************************************
// LZO requires a conforming <limits.h>
************************************************************************/
#if !defined(CHAR_BIT) || (CHAR_BIT != 8)
# error "invalid CHAR_BIT"
#endif
#if !defined(UCHAR_MAX) || !defined(USHRT_MAX) || !defined(UINT_MAX) || !defined(ULONG_MAX)
# error "check your compiler installation"
#endif
#if (USHRT_MAX < 1) || (UINT_MAX < 1) || (ULONG_MAX < 1)
# error "your limits.h macros are broken"
#endif
/* get OS and architecture defines */
#ifndef __LZODEFS_H_INCLUDED
#include <lzo/lzodefs.h>
#endif
#ifdef __cplusplus
extern "C" {
#endif
/***********************************************************************
// some core defines
************************************************************************/
/* memory checkers */
#if !defined(__LZO_CHECKER)
# if defined(__BOUNDS_CHECKING_ON)
# define __LZO_CHECKER 1
# elif defined(__CHECKER__)
# define __LZO_CHECKER 1
# elif defined(__INSURE__)
# define __LZO_CHECKER 1
# elif defined(__PURIFY__)
# define __LZO_CHECKER 1
# endif
#endif
/***********************************************************************
// integral and pointer types
************************************************************************/
/* lzo_uint must match size_t */
#if !defined(LZO_UINT_MAX)
# if (LZO_ABI_LLP64)
# if (LZO_OS_WIN64)
typedef unsigned __int64 lzo_uint;
typedef __int64 lzo_int;
# define LZO_TYPEOF_LZO_INT LZO_TYPEOF___INT64
# else
typedef lzo_ullong_t lzo_uint;
typedef lzo_llong_t lzo_int;
# define LZO_TYPEOF_LZO_INT LZO_TYPEOF_LONG_LONG
# endif
# define LZO_SIZEOF_LZO_INT 8
# define LZO_UINT_MAX 0xffffffffffffffffull
# define LZO_INT_MAX 9223372036854775807LL
# define LZO_INT_MIN (-1LL - LZO_INT_MAX)
# elif (LZO_ABI_IP32L64) /* MIPS R5900 */
typedef unsigned int lzo_uint;
typedef int lzo_int;
# define LZO_SIZEOF_LZO_INT LZO_SIZEOF_INT
# define LZO_TYPEOF_LZO_INT LZO_TYPEOF_INT
# define LZO_UINT_MAX UINT_MAX
# define LZO_INT_MAX INT_MAX
# define LZO_INT_MIN INT_MIN
# elif (ULONG_MAX >= LZO_0xffffffffL)
typedef unsigned long lzo_uint;
typedef long lzo_int;
# define LZO_SIZEOF_LZO_INT LZO_SIZEOF_LONG
# define LZO_TYPEOF_LZO_INT LZO_TYPEOF_LONG
# define LZO_UINT_MAX ULONG_MAX
# define LZO_INT_MAX LONG_MAX
# define LZO_INT_MIN LONG_MIN
# else
# error "lzo_uint"
# endif
#endif
/* The larger type of lzo_uint and lzo_uint32_t. */
#if (LZO_SIZEOF_LZO_INT >= 4)
# define lzo_xint lzo_uint
#else
# define lzo_xint lzo_uint32_t
#endif
typedef int lzo_bool;
/* sanity checks */
LZO_COMPILE_TIME_ASSERT_HEADER(sizeof(lzo_int) == LZO_SIZEOF_LZO_INT)
LZO_COMPILE_TIME_ASSERT_HEADER(sizeof(lzo_uint) == LZO_SIZEOF_LZO_INT)
LZO_COMPILE_TIME_ASSERT_HEADER(sizeof(lzo_xint) >= sizeof(lzo_uint))
LZO_COMPILE_TIME_ASSERT_HEADER(sizeof(lzo_xint) >= sizeof(lzo_uint32_t))
#ifndef __LZO_MMODEL
#define __LZO_MMODEL /*empty*/
#endif
/* no typedef here because of const-pointer issues */
#define lzo_bytep unsigned char __LZO_MMODEL *
#define lzo_charp char __LZO_MMODEL *
#define lzo_voidp void __LZO_MMODEL *
#define lzo_shortp short __LZO_MMODEL *
#define lzo_ushortp unsigned short __LZO_MMODEL *
#define lzo_intp lzo_int __LZO_MMODEL *
#define lzo_uintp lzo_uint __LZO_MMODEL *
#define lzo_xintp lzo_xint __LZO_MMODEL *
#define lzo_voidpp lzo_voidp __LZO_MMODEL *
#define lzo_bytepp lzo_bytep __LZO_MMODEL *
#define lzo_int8_tp lzo_int8_t __LZO_MMODEL *
#define lzo_uint8_tp lzo_uint8_t __LZO_MMODEL *
#define lzo_int16_tp lzo_int16_t __LZO_MMODEL *
#define lzo_uint16_tp lzo_uint16_t __LZO_MMODEL *
#define lzo_int32_tp lzo_int32_t __LZO_MMODEL *
#define lzo_uint32_tp lzo_uint32_t __LZO_MMODEL *
#if defined(lzo_int64_t)
#define lzo_int64_tp lzo_int64_t __LZO_MMODEL *
#define lzo_uint64_tp lzo_uint64_t __LZO_MMODEL *
#endif
/* Older LZO versions used to support ancient systems and memory models
* such as 16-bit MSDOS with __huge pointers or Cray PVP, but these
* obsolete configurations are not supported any longer.
*/
#if defined(__LZO_MMODEL_HUGE)
#error "__LZO_MMODEL_HUGE memory model is unsupported"
#endif
#if (LZO_MM_PVP)
#error "LZO_MM_PVP memory model is unsupported"
#endif
#if (LZO_SIZEOF_INT < 4)
#error "LZO_SIZEOF_INT < 4 is unsupported"
#endif
#if (__LZO_UINTPTR_T_IS_POINTER)
#error "__LZO_UINTPTR_T_IS_POINTER is unsupported"
#endif
LZO_COMPILE_TIME_ASSERT_HEADER(sizeof(int) >= 4)
LZO_COMPILE_TIME_ASSERT_HEADER(sizeof(lzo_uint) >= 4)
/* Strange configurations where sizeof(lzo_uint) != sizeof(size_t) should
* work but have not received much testing lately, so be strict here.
*/
LZO_COMPILE_TIME_ASSERT_HEADER(sizeof(lzo_uint) == sizeof(size_t))
LZO_COMPILE_TIME_ASSERT_HEADER(sizeof(lzo_uint) == sizeof(ptrdiff_t))
LZO_COMPILE_TIME_ASSERT_HEADER(sizeof(lzo_uint) == sizeof(lzo_uintptr_t))
LZO_COMPILE_TIME_ASSERT_HEADER(sizeof(void *) == sizeof(lzo_uintptr_t))
LZO_COMPILE_TIME_ASSERT_HEADER(sizeof(char *) == sizeof(lzo_uintptr_t))
LZO_COMPILE_TIME_ASSERT_HEADER(sizeof(long *) == sizeof(lzo_uintptr_t))
LZO_COMPILE_TIME_ASSERT_HEADER(sizeof(void *) == sizeof(lzo_voidp))
LZO_COMPILE_TIME_ASSERT_HEADER(sizeof(char *) == sizeof(lzo_bytep))
/***********************************************************************
// function types
************************************************************************/
/* name mangling */
#if !defined(__LZO_EXTERN_C)
# ifdef __cplusplus
# define __LZO_EXTERN_C extern "C"
# else
# define __LZO_EXTERN_C extern
# endif
#endif
/* calling convention */
#if !defined(__LZO_CDECL)
# define __LZO_CDECL __lzo_cdecl
#endif
/* DLL export information */
#if !defined(__LZO_EXPORT1)
# define __LZO_EXPORT1 /*empty*/
#endif
#if !defined(__LZO_EXPORT2)
# define __LZO_EXPORT2 /*empty*/
#endif
/* __cdecl calling convention for public C and assembly functions */
#if !defined(LZO_PUBLIC)
# define LZO_PUBLIC(r) __LZO_EXPORT1 r __LZO_EXPORT2 __LZO_CDECL
#endif
#if !defined(LZO_EXTERN)
# define LZO_EXTERN(r) __LZO_EXTERN_C LZO_PUBLIC(r)
#endif
#if !defined(LZO_PRIVATE)
# define LZO_PRIVATE(r) static r __LZO_CDECL
#endif
/* function types */
typedef int
(__LZO_CDECL *lzo_compress_t) ( const lzo_bytep src, lzo_uint src_len,
lzo_bytep dst, lzo_uintp dst_len,
lzo_voidp wrkmem );
typedef int
(__LZO_CDECL *lzo_decompress_t) ( const lzo_bytep src, lzo_uint src_len,
lzo_bytep dst, lzo_uintp dst_len,
lzo_voidp wrkmem );
typedef int
(__LZO_CDECL *lzo_optimize_t) ( lzo_bytep src, lzo_uint src_len,
lzo_bytep dst, lzo_uintp dst_len,
lzo_voidp wrkmem );
typedef int
(__LZO_CDECL *lzo_compress_dict_t)(const lzo_bytep src, lzo_uint src_len,
lzo_bytep dst, lzo_uintp dst_len,
lzo_voidp wrkmem,
const lzo_bytep dict, lzo_uint dict_len );
typedef int
(__LZO_CDECL *lzo_decompress_dict_t)(const lzo_bytep src, lzo_uint src_len,
lzo_bytep dst, lzo_uintp dst_len,
lzo_voidp wrkmem,
const lzo_bytep dict, lzo_uint dict_len );
/* Callback interface. Currently only the progress indicator ("nprogress")
* is used, but this may change in a future release. */
struct lzo_callback_t;
typedef struct lzo_callback_t lzo_callback_t;
#define lzo_callback_p lzo_callback_t __LZO_MMODEL *
/* malloc & free function types */
typedef lzo_voidp (__LZO_CDECL *lzo_alloc_func_t)
(lzo_callback_p self, lzo_uint items, lzo_uint size);
typedef void (__LZO_CDECL *lzo_free_func_t)
(lzo_callback_p self, lzo_voidp ptr);
/* a progress indicator callback function */
typedef void (__LZO_CDECL *lzo_progress_func_t)
(lzo_callback_p, lzo_uint, lzo_uint, int);
struct lzo_callback_t
{
/* custom allocators (set to 0 to disable) */
lzo_alloc_func_t nalloc; /* [not used right now] */
lzo_free_func_t nfree; /* [not used right now] */
/* a progress indicator callback function (set to 0 to disable) */
lzo_progress_func_t nprogress;
/* INFO: the first parameter "self" of the nalloc/nfree/nprogress
* callbacks points back to this struct, so you are free to store
* some extra info in the following variables. */
lzo_voidp user1;
lzo_xint user2;
lzo_xint user3;
};
/***********************************************************************
// error codes and prototypes
************************************************************************/
/* Error codes for the compression/decompression functions. Negative
* values are errors, positive values will be used for special but
* normal events.
*/
#define LZO_E_OK 0
#define LZO_E_ERROR (-1)
#define LZO_E_OUT_OF_MEMORY (-2) /* [lzo_alloc_func_t failure] */
#define LZO_E_NOT_COMPRESSIBLE (-3) /* [not used right now] */
#define LZO_E_INPUT_OVERRUN (-4)
#define LZO_E_OUTPUT_OVERRUN (-5)
#define LZO_E_LOOKBEHIND_OVERRUN (-6)
#define LZO_E_EOF_NOT_FOUND (-7)
#define LZO_E_INPUT_NOT_CONSUMED (-8)
#define LZO_E_NOT_YET_IMPLEMENTED (-9) /* [not used right now] */
#define LZO_E_INVALID_ARGUMENT (-10)
#define LZO_E_INVALID_ALIGNMENT (-11) /* pointer argument is not properly aligned */
#define LZO_E_OUTPUT_NOT_CONSUMED (-12)
#define LZO_E_INTERNAL_ERROR (-99)
#ifndef lzo_sizeof_dict_t
# define lzo_sizeof_dict_t ((unsigned)sizeof(lzo_bytep))
#endif
/* lzo_init() should be the first function you call.
* Check the return code !
*
* lzo_init() is a macro to allow checking that the library and the
* compiler's view of various types are consistent.
*/
#define lzo_init() __lzo_init_v2(LZO_VERSION,(int)sizeof(short),(int)sizeof(int),\
(int)sizeof(long),(int)sizeof(lzo_uint32_t),(int)sizeof(lzo_uint),\
(int)lzo_sizeof_dict_t,(int)sizeof(char *),(int)sizeof(lzo_voidp),\
(int)sizeof(lzo_callback_t))
LZO_EXTERN(int) __lzo_init_v2(unsigned,int,int,int,int,int,int,int,int,int);
/* version functions (useful for shared libraries) */
LZO_EXTERN(unsigned) lzo_version(void);
LZO_EXTERN(const char *) lzo_version_string(void);
LZO_EXTERN(const char *) lzo_version_date(void);
LZO_EXTERN(const lzo_charp) _lzo_version_string(void);
LZO_EXTERN(const lzo_charp) _lzo_version_date(void);
/* string functions */
LZO_EXTERN(int)
lzo_memcmp(const lzo_voidp a, const lzo_voidp b, lzo_uint len);
LZO_EXTERN(lzo_voidp)
lzo_memcpy(lzo_voidp dst, const lzo_voidp src, lzo_uint len);
LZO_EXTERN(lzo_voidp)
lzo_memmove(lzo_voidp dst, const lzo_voidp src, lzo_uint len);
LZO_EXTERN(lzo_voidp)
lzo_memset(lzo_voidp buf, int c, lzo_uint len);
/* checksum functions */
LZO_EXTERN(lzo_uint32_t)
lzo_adler32(lzo_uint32_t c, const lzo_bytep buf, lzo_uint len);
LZO_EXTERN(lzo_uint32_t)
lzo_crc32(lzo_uint32_t c, const lzo_bytep buf, lzo_uint len);
LZO_EXTERN(const lzo_uint32_tp)
lzo_get_crc32_table(void);
/* misc. */
LZO_EXTERN(int) _lzo_config_check(void);
typedef union {
lzo_voidp a00; lzo_bytep a01; lzo_uint a02; lzo_xint a03; lzo_uintptr_t a04;
void *a05; unsigned char *a06; unsigned long a07; size_t a08; ptrdiff_t a09;
#if defined(lzo_int64_t)
lzo_uint64_t a10;
#endif
} lzo_align_t;
/* align a char pointer on a boundary that is a multiple of 'size' */
LZO_EXTERN(unsigned) __lzo_align_gap(const lzo_voidp p, lzo_uint size);
#define LZO_PTR_ALIGN_UP(p,size) \
((p) + (lzo_uint) __lzo_align_gap((const lzo_voidp)(p),(lzo_uint)(size)))
/***********************************************************************
// deprecated macros - only for backward compatibility
************************************************************************/
/* deprecated - use 'lzo_bytep' instead of 'lzo_byte *' */
#define lzo_byte unsigned char
/* deprecated type names */
#define lzo_int32 lzo_int32_t
#define lzo_uint32 lzo_uint32_t
#define lzo_int32p lzo_int32_t __LZO_MMODEL *
#define lzo_uint32p lzo_uint32_t __LZO_MMODEL *
#define LZO_INT32_MAX LZO_INT32_C(2147483647)
#define LZO_UINT32_MAX LZO_UINT32_C(4294967295)
#if defined(lzo_int64_t)
#define lzo_int64 lzo_int64_t
#define lzo_uint64 lzo_uint64_t
#define lzo_int64p lzo_int64_t __LZO_MMODEL *
#define lzo_uint64p lzo_uint64_t __LZO_MMODEL *
#define LZO_INT64_MAX LZO_INT64_C(9223372036854775807)
#define LZO_UINT64_MAX LZO_UINT64_C(18446744073709551615)
#endif
/* deprecated types */
typedef union { lzo_bytep a; lzo_uint b; } __lzo_pu_u;
typedef union { lzo_bytep a; lzo_uint32_t b; } __lzo_pu32_u;
/* deprecated defines */
#if !defined(LZO_SIZEOF_LZO_UINT)
# define LZO_SIZEOF_LZO_UINT LZO_SIZEOF_LZO_INT
#endif
#if defined(LZO_CFG_COMPAT)
#define __LZOCONF_H 1
#if defined(LZO_ARCH_I086)
# define __LZO_i386 1
#elif defined(LZO_ARCH_I386)
# define __LZO_i386 1
#endif
#if defined(LZO_OS_DOS16)
# define __LZO_DOS 1
# define __LZO_DOS16 1
#elif defined(LZO_OS_DOS32)
# define __LZO_DOS 1
#elif defined(LZO_OS_WIN16)
# define __LZO_WIN 1
# define __LZO_WIN16 1
#elif defined(LZO_OS_WIN32)
# define __LZO_WIN 1
#endif
#define __LZO_CMODEL /*empty*/
#define __LZO_DMODEL /*empty*/
#define __LZO_ENTRY __LZO_CDECL
#define LZO_EXTERN_CDECL LZO_EXTERN
#define LZO_ALIGN LZO_PTR_ALIGN_UP
#define lzo_compress_asm_t lzo_compress_t
#define lzo_decompress_asm_t lzo_decompress_t
#endif /* LZO_CFG_COMPAT */
#ifdef __cplusplus
} /* extern "C" */
#endif
#endif /* already included */
/* vim:set ts=4 sw=4 et: */
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+106
View File
@@ -0,0 +1,106 @@
/* minilzo.h -- mini subset of the LZO real-time data compression library
This file is part of the LZO real-time data compression library.
Copyright (C) 1996-2017 Markus Franz Xaver Johannes Oberhumer
All Rights Reserved.
The LZO library is free software; you can redistribute it and/or
modify it under the terms of the GNU General Public License as
published by the Free Software Foundation; either version 2 of
the License, or (at your option) any later version.
The LZO library is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with the LZO library; see the file COPYING.
If not, write to the Free Software Foundation, Inc.,
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
Markus F.X.J. Oberhumer
<markus@oberhumer.com>
http://www.oberhumer.com/opensource/lzo/
*/
/*
* NOTE:
* the full LZO package can be found at
* http://www.oberhumer.com/opensource/lzo/
*/
#ifndef __MINILZO_H_INCLUDED
#define __MINILZO_H_INCLUDED 1
#define MINILZO_VERSION 0x20a0 /* 2.10 */
#if defined(__LZOCONF_H_INCLUDED)
# error "you cannot use both LZO and miniLZO"
#endif
/* internal Autoconf configuration file - only used when building miniLZO */
#ifdef MINILZO_HAVE_CONFIG_H
# include <config.h>
#endif
#include <limits.h>
#include <stddef.h>
#ifndef __LZODEFS_H_INCLUDED
#include "lzodefs.h"
#endif
#undef LZO_HAVE_CONFIG_H
#include "lzoconf.h"
#if !defined(LZO_VERSION) || (LZO_VERSION != MINILZO_VERSION)
# error "version mismatch in header files"
#endif
#ifdef __cplusplus
extern "C" {
#endif
/***********************************************************************
//
************************************************************************/
/* Memory required for the wrkmem parameter.
* When the required size is 0, you can also pass a NULL pointer.
*/
#define LZO1X_MEM_COMPRESS LZO1X_1_MEM_COMPRESS
#define LZO1X_1_MEM_COMPRESS ((lzo_uint32_t) (16384L * lzo_sizeof_dict_t))
#define LZO1X_MEM_DECOMPRESS (0)
/* compression */
LZO_EXTERN(int)
lzo1x_1_compress ( const lzo_bytep src, lzo_uint src_len,
lzo_bytep dst, lzo_uintp dst_len,
lzo_voidp wrkmem );
/* decompression */
LZO_EXTERN(int)
lzo1x_decompress ( const lzo_bytep src, lzo_uint src_len,
lzo_bytep dst, lzo_uintp dst_len,
lzo_voidp wrkmem /* NOT USED */ );
/* safe decompression with overrun testing */
LZO_EXTERN(int)
lzo1x_decompress_safe ( const lzo_bytep src, lzo_uint src_len,
lzo_bytep dst, lzo_uintp dst_len,
lzo_voidp wrkmem /* NOT USED */ );
#ifdef __cplusplus
} /* extern "C" */
#endif
#endif /* already included */
/* vim:set ts=4 sw=4 et: */
Vendored Submodule
+1
+467
View File
@@ -0,0 +1,467 @@
// net_e2e — drive the full client login flow against a REAL server:
// auth handshake -> CG_LOGIN3 -> GC_AUTH_SUCCESS
// -> game server: CG_LOGIN2 -> char list -> select_character -> PHASE_GAME
// -> pump a few seconds, dump entities / points / inventory / party.
//
// net_e2e [auth_host] [auth_port] [game_host] [game_port] [id] [pw] [char_index] [seconds]
// defaults: 192.168.21.203 11000 192.168.21.203 11011 admin 123456789 0 8
// MT_E2E_SWEEP=1 enables the one-session safe protocol matrix; add
// MT_E2E_UNSAFE=1 only when warp/dungeon probes are explicitly authorized.
//
// Mirrors M2Client::pump_auth/pump_game orchestration without the Godot layer.
#include "../src/net/auth_client.h"
#include "../src/net/game_client.h"
#include <chrono>
#include <cstdio>
#include <cstdlib>
#include <functional>
#include <string>
#include <thread>
using clock_t_ = std::chrono::steady_clock;
static double elapsed(clock_t_::time_point start) {
return std::chrono::duration_cast<std::chrono::milliseconds>(clock_t_::now() - start).count() /
1000.0;
}
int main(int argc, char **argv) {
std::string auth_host = argc > 1 ? argv[1] : "192.168.21.203";
uint16_t auth_port = argc > 2 ? (uint16_t)std::stoi(argv[2]) : 11000;
std::string game_host = argc > 3 ? argv[3] : "192.168.21.203";
uint16_t game_port = argc > 4 ? (uint16_t)std::stoi(argv[4]) : 11011;
std::string id = argc > 5 ? argv[5] : "admin";
std::string pw = argc > 6 ? argv[6] : "123456789";
int char_index = argc > 7 ? std::atoi(argv[7]) : 0;
double run_secs = argc > 8 ? std::atof(argv[8]) : 8.0;
std::printf("net_e2e -> auth %s:%u game %s:%u id=%s char=%d\n", auth_host.c_str(), auth_port,
game_host.c_str(), game_port, id.c_str(), char_index);
bool trace = std::getenv("MT_NET_TRACE") != nullptr;
// ---- phase 1: auth server ------------------------------------------------
mtnet::AuthClient auth(id, pw);
auth.set_wire_trace(trace);
if (!auth.connect(auth_host, auth_port)) {
std::printf("FAIL: auth connect(): %s\n", auth.last_error().c_str());
return 2;
}
auto start = clock_t_::now();
while (!auth.done() && elapsed(start) < 15.0) {
auth.process();
std::this_thread::sleep_for(std::chrono::milliseconds(15));
}
if (!auth.done() || !auth.success()) {
std::printf("FAIL: auth (%s)\n", auth.fail_reason().empty() ? auth.last_error().c_str()
: auth.fail_reason().c_str());
return 3;
}
uint32_t login_key = auth.login_key();
std::printf("[auth] OK login_key=0x%08X\n", login_key);
auth.disconnect();
// ---- phase 2: game server ---------------------------------------------
mtnet::GameClient game(id, login_key);
game.set_wire_trace(trace);
game.set_auto_enter_game(false); // this tool sends it on its own schedule
if (std::getenv("MT_NET_DUMP")) {
for (uint16_t h : {0x0205, 0x0206, 0x0207, 0x0209, 0x020A, 0x0214, 0x0215,
// round 2
0x021B, 0x0519, 0x051A, 0x051B, 0x0730, 0x0A20, 0x0514, 0x0A30,
0x0A31, 0x0A50, 0x0912, 0x0603, 0x0304, 0x0307, 0x0217, 0x0216,
0x0410, 0x0413, 0x0A11, 0x0A12, 0x0A13,
// post-2026-08-30: create/delete, dragon soul, mall
0x020C, 0x020D, 0x020E, 0x020F, 0x051F, 0x0841, 0x0842, 0x0843, 0x0109}) {
game.dump_header(h);
}
}
const bool e2e_charcreate = std::getenv("MT_E2E_CHARCREATE") != nullptr;
const bool e2e_myshop = std::getenv("MT_E2E_MYSHOP") != nullptr;
const bool e2e_cube = std::getenv("MT_E2E_CUBE") != nullptr;
const bool e2e_sweep = std::getenv("MT_E2E_SWEEP") != nullptr;
const bool e2e_unsafe = std::getenv("MT_E2E_UNSAFE") != nullptr;
if (!game.connect(game_host, game_port)) {
std::printf("FAIL: game connect(): %s\n", game.last_error().c_str());
return 4;
}
start = clock_t_::now();
bool selected = false;
double select_t = 0;
bool tried_empire = false;
double loading_t = 0;
bool enter_sent = false;
int last_phase = -1;
while (elapsed(start) < 30.0) {
if (game.phase() == mtnet::PHASE_LOADING && loading_t == 0) {
loading_t = elapsed(start);
}
// give the server ~1.5s to finish the spawn burst, then say we're ready
if (game.phase() == mtnet::PHASE_LOADING && !enter_sent && loading_t > 0 &&
elapsed(start) - loading_t > 1.5) {
std::printf("[game] send CG_ENTERGAME (%.1fs into LOADING)\n", elapsed(start) - loading_t);
game.send_enter_game();
enter_sent = true;
}
game.world().set_now((uint32_t)(elapsed(start) * 1000.0));
game.process();
game.world().tick();
if ((int)game.phase() != last_phase) {
last_phase = (int)game.phase();
std::printf("[game] phase = %d (t=%.1fs)\n", last_phase, elapsed(start));
}
if (game.state() == mtnet::NetStream::State::Offline) {
std::printf("FAIL: game server dropped us (%s) at phase %d\n", game.last_error().c_str(),
(int)game.phase());
return 8;
}
if (game.char_list_ready() && !selected) {
std::printf("[game] char list: %zu slot(s) (empire byte=%d seen=%d)\n",
game.chars().size(), game.empire(), (int)game.empire_seen());
for (const auto &c : game.chars()) {
std::printf(" [%d] %-16s job=%d lv=%d (%d,%d)\n", c.index, c.name.c_str(),
c.job, c.level, c.x, c.y);
}
if (game.chars().empty()) {
std::printf("FAIL: no characters on the account\n");
return 5;
}
int idx = game.chars()[0].index;
for (const auto &c : game.chars()) {
if (c.index == char_index) {
idx = c.index;
}
}
// --- non-destructive create test: CG_CHARACTER_CREATE on an OCCUPIED
// slot must come back as GC_PLAYER_CREATE_FAILURE (nothing mutated).
if (e2e_charcreate) {
std::printf("[e2e] CG_CHARACTER_CREATE on occupied slot %d (expect FAILURE)\n", idx);
game.create_character(idx, "ZzTestName", 0, 0, 4, 3, 6, 3);
double t0 = elapsed(start);
bool got = false;
while (elapsed(start) - t0 < 3.0 && !got) {
game.process();
for (const auto &ev : game.drain_char_events()) {
using K = mtnet::GameClient::CharEvent::Kind;
if (ev.kind == K::CreateFail) {
std::printf("[e2e] -> GC_PLAYER_CREATE_FAILURE type=%d (struct OK)\n",
ev.fail_type);
got = true;
} else if (ev.kind == K::CreateOk) {
std::printf("[e2e] !! GC_PLAYER_CREATE_SUCCESS on occupied slot "
"(slot=%d) — unexpected, check slot choice\n",
ev.slot);
got = true;
}
}
std::this_thread::sleep_for(std::chrono::milliseconds(20));
}
if (!got) {
std::printf("[e2e] !! no create result in 3s (last_unknown=0x%04X) — "
"CG_CHARACTER_CREATE layout may be wrong\n",
game.last_unknown_header());
}
}
std::printf("[game] select_character(%d) -> %s\n", idx,
game.select_character(idx) ? "sent" : "SEND FAILED");
selected = true;
select_t = elapsed(start);
}
// stuck at SELECT >3s after selecting: try an explicit empire choice + reselect
if (selected && !tried_empire && game.phase() == mtnet::PHASE_SELECT &&
elapsed(start) - select_t > 3.0) {
std::printf("[game] still at SELECT %.1fs after select; sending CG_EMPIRE(1) + reselect\n",
elapsed(start) - select_t);
game.send_empire(1);
std::this_thread::sleep_for(std::chrono::milliseconds(100));
game.process();
game.select_character(char_index);
tried_empire = true;
}
if (game.failed()) {
std::printf("FAIL: game (%s)\n", game.fail_reason().c_str());
return 6;
}
if (game.in_game()) {
break;
}
std::this_thread::sleep_for(std::chrono::milliseconds(15));
}
if (!game.in_game()) {
std::printf("FAIL: never reached PHASE_GAME (phase=%d, last_unknown=0x%04X)\n",
(int)game.phase(), game.last_unknown_header());
return 7;
}
std::printf("[game] IN GAME (t=%.1fs)\n", elapsed(start));
// ---- phase 2.25: one-session protocol sweep ---------------------------
// These are deliberately sent one at a time with a short pump between them.
// A live socket only proves that the server accepted/framed the packet; a
// state/event change is printed separately as stronger evidence. Destructive
// requests (warp/dungeon/change-name/item mutation) stay behind MT_E2E_UNSAFE.
if (e2e_sweep) {
std::printf("\n=== one-session protocol sweep (safe subset) ===\n");
int sweep_sent = 0;
int sweep_alive = 0;
int sweep_events = 0;
auto pump_probe = [&](double seconds) {
auto p0 = clock_t_::now();
while (elapsed(p0) < seconds) {
game.world().set_now((uint32_t)(12000 + elapsed(p0) * 1000.0));
game.process();
game.world().tick();
if (game.state() == mtnet::NetStream::State::Offline) {
break;
}
std::this_thread::sleep_for(std::chrono::milliseconds(20));
}
};
auto probe = [&](const char *name, uint16_t header, const std::function<bool()> &send,
const std::function<int()> &events = std::function<int()>()) {
const uint16_t unknown_before = game.last_unknown_header();
const bool sent = send();
if (sent) {
++sweep_sent;
}
pump_probe(0.45);
const bool alive = game.state() != mtnet::NetStream::State::Offline;
if (alive) {
++sweep_alive;
}
int event_count = events ? events() : 0;
sweep_events += event_count;
std::printf("[sweep] %-24s tx=0x%04X sent=%d alive=%d events=%d "
"unknown_before=0x%04X unknown_after=0x%04X\n",
name, header, (int)sent, (int)alive, event_count, unknown_before,
game.last_unknown_header());
};
const uint32_t self_vid = game.world().main_vid();
const mtnet::Entity *self = game.world().get(self_vid);
const int32_t self_x = self ? (int32_t)self->x : 0;
const int32_t self_y = self ? (int32_t)self->y : 0;
probe("CG_CHARACTER_POSITION", mtnet::CG_CHARACTER_POSITION,
[&]() { return game.send_character_position(0); });
mtnet::CGSyncPositionElement sync{};
sync.vid = self_vid;
sync.x = self_x;
sync.y = self_y;
probe("CG_SYNC_POSITION", mtnet::CG_SYNC_POSITION,
[&]() { return game.send_sync_positions({sync}); });
probe("CG_SCRIPT_SELECT_ITEM", mtnet::CG_SCRIPT_SELECT_ITEM,
[&]() { return game.send_script_select_item(0); },
[&]() { return (int)game.world().drain_scripts().size(); });
probe("CG_QUEST_CANCEL", mtnet::CG_QUEST_CANCEL,
[&]() { return game.send_quest_cancel(); },
[&]() { return (int)game.world().drain_quest_changes().size(); });
probe("CG_PARTY_USE_SKILL", mtnet::CG_PARTY_USE_SKILL,
[&]() { return game.send_party_use_skill(0, self_vid); });
probe("CG_FLY_TARGETING", mtnet::CG_FLY_TARGETING,
[&]() { return game.send_fly_targeting(self_vid, self_x, self_y); },
[&]() { return (int)game.world().drain_fly_target_cues().size(); });
probe("CG_ADD_FLY_TARGETING", mtnet::CG_ADD_FLY_TARGETING,
[&]() { return game.send_add_fly_targeting(self_vid, self_x, self_y); },
[&]() { return (int)game.world().drain_fly_target_cues().size(); });
probe("CG_FISHING", mtnet::CG_FISHING,
[&]() { return game.send_fishing(0); },
[&]() { return (int)game.world().drain_fishing_events().size(); });
probe("CG_SHOOT", mtnet::CG_SHOOT,
[&]() { return game.send_shoot(0); });
probe("CG_USE_SKILL", mtnet::CG_USE_SKILL,
[&]() { return game.send_use_skill(0, self_vid); });
if (e2e_unsafe) {
probe("CG_DUNGEON (unsafe)", mtnet::CG_DUNGEON,
[&]() { return game.send_dungeon(); },
[&]() { return (int)game.world().drain_dungeon_events().size(); });
probe("CG_WARP (unsafe)", mtnet::CG_WARP,
[&]() { return game.send_warp(); });
} else {
std::printf("[sweep] CG_DUNGEON/CG_WARP skipped (set MT_E2E_UNSAFE=1)\n");
}
std::printf("[sweep] summary sent=%d alive_after_probe=%d observed_events=%d "
"still_connected=%d\n",
sweep_sent, sweep_alive, sweep_events,
(int)(game.state() != mtnet::NetStream::State::Offline));
}
// ---- phase 2.5: exercise post-2026-08-30 protocols -------------------
std::printf("\n=== post-2026-08-30 protocol probes ===\n");
std::printf("[e2e] GC_EMPIRE: empire byte=%d seen=%d\n", game.empire(),
(int)game.empire_seen());
// cube: /cube rList <npc> is a plain chat command; server only answers when
// the player is actually at a cube NPC, so this mostly proves the send path.
if (e2e_cube) {
uint32_t cube_npc = 20383; // common blacksmith/cube NPC vnum
std::printf("[e2e] send '/cube rList %u'\n", cube_npc);
game.send_cube_result_list(cube_npc);
}
if (e2e_myshop) {
// open a 1-item shop from inventory cell 0 at a silly price, then close.
std::vector<mtnet::MyShopItem> items;
bool have_item = false;
for (int c = 0; c < mtnet::INVENTORY_MAX_NUM && !have_item; ++c) {
const mtnet::Item &it = game.world().inv_slot(c);
if (!it.empty()) {
mtnet::MyShopItem e{};
e.vnum = it.vnum;
e.count = it.count ? it.count : 1;
e.pos = {mtnet::WINDOW_INVENTORY, (uint16_t)c};
e.price = 99999999;
e.display_pos = 0;
items.push_back(e);
have_item = true;
std::printf("[e2e] CG_MYSHOP: 1 item (vnum=%u inv_cell=%d) price=99999999\n",
e.vnum, c);
}
}
if (!have_item) {
std::printf("[e2e] CG_MYSHOP: inventory empty, opening a 0-item shop\n");
}
game.send_open_private_shop("e2e test shop", items);
}
// pump ~3.5s so any server push (guild skill/war, shop broadcast) lands
{
auto p0 = clock_t_::now();
while (elapsed(p0) < 3.5) {
game.world().set_now((uint32_t)(15000 + elapsed(p0) * 1000.0));
game.process();
game.world().tick();
if (game.state() == mtnet::NetStream::State::Offline) {
std::printf("[e2e] !! DROPPED during probe pump: %s\n", game.last_error().c_str());
break;
}
std::this_thread::sleep_for(std::chrono::milliseconds(20));
}
}
{
const mtnet::GuildSkillState &gs = game.world().guild_skill();
std::printf("[e2e] GUILD_GC_SKILL_INFO: valid=%d skill_point=%d guild_point=%d/%d "
"levels=[",
(int)gs.valid, gs.skill_point, gs.guild_point, gs.max_guild_point);
for (int i = 0; i < mtnet::GUILD_SKILL_MAX_NUM; ++i) {
std::printf("%d%s", gs.levels[i], i + 1 < mtnet::GUILD_SKILL_MAX_NUM ? "," : "");
}
std::printf("]\n");
const mtnet::GuildWarStatus &gw = game.world().guild_war();
std::printf("[e2e] GUILD_GC_WAR: state=%d type=%d opp_guild=%u active GvG pairs=%zu\n",
gw.state, gw.type, gw.opp_guild_id, game.world().guild_wars().size());
}
if (e2e_myshop) {
std::printf("[e2e] CG_MYSHOP: closing shop (SHOP_CG_END)\n");
game.send_close_private_shop();
auto p0 = clock_t_::now();
while (elapsed(p0) < 1.5) {
game.process();
std::this_thread::sleep_for(std::chrono::milliseconds(20));
}
for (const auto &err : game.world().drain_shop_errors()) {
std::printf("[e2e] shop error: %s\n", err.c_str());
}
}
std::printf("[e2e] cube state: open=%d npc=%u recipes=%zu results=%zu\n",
(int)game.world().cube().open, game.world().cube().npc_vnum,
game.world().cube().recipes.size(), game.world().cube().results.size());
std::printf("[e2e] mall state: open=%d size=%d\n", (int)game.world().mall_open(),
game.world().mall_size());
std::printf("[e2e] still connected after probes: %s\n",
game.state() == mtnet::NetStream::State::Offline ? "NO (dropped)" : "yes");
// ---- phase 3: observe + poke (move / attack with zero CRC) ---------
auto game_start = clock_t_::now();
bool poked = false;
while (elapsed(game_start) < run_secs) {
game.world().set_now((uint32_t)(20000 + elapsed(game_start) * 1000.0));
game.process();
game.world().tick();
if (game.state() == mtnet::NetStream::State::Offline) {
std::printf("[game] disconnected after %.1fs in game: %s\n", elapsed(game_start),
game.last_error().c_str());
break;
}
// 2s in: send a move + an attack (crc fields 0) and see if we survive
if (!poked && elapsed(game_start) > 2.0) {
const mtnet::Entity *me = game.world().get(game.world().main_vid());
int32_t x = me ? (int32_t)me->x : 0, y = me ? (int32_t)me->y : 0;
game.send_move(mtnet::FUNC_MOVE, 0, 0, x + 100, y, 2000);
game.send_attack(0, game.world().main_vid());
std::printf("[game] sent CG_MOVE + CG_ATTACK (crc=0) at +2s\n");
poked = true;
}
std::this_thread::sleep_for(std::chrono::milliseconds(20));
}
if (game.state() != mtnet::NetStream::State::Offline) {
std::printf("[game] still connected at +%.0fs (zero-CRC attack accepted)\n", run_secs);
}
mtnet::EntityStore &w = game.world();
std::printf("\n=== snapshot after %.0fs in game ===\n", run_secs);
std::printf("main vid: %u\n", w.main_vid());
if (const mtnet::Entity *me = w.get(w.main_vid())) {
std::printf(" name=%s race=%u pos=(%.0f,%.0f,%.0f) hp=%d/%d level=%d\n", me->name.c_str(),
me->race, me->x, me->y, me->z, me->hp, me->max_hp, me->level);
}
std::printf("entities in view: %zu\n", w.size());
int named = 0, mounted = 0;
for (uint32_t vid : w.vids()) {
const mtnet::Entity *e = w.get(vid);
if (!e->name.empty() && vid != w.main_vid()) {
if (named++ < 10) {
std::printf(" NAMED vid=%-7u race=%-5u lvl=%-4d guild=%d pk=%d mount=%u \"%s\"\n",
vid, e->race, e->level, e->guild, e->pk_mode, e->mount_vnum,
e->name.c_str());
}
}
if (e->mount_vnum != 0) {
++mounted;
}
}
std::printf("named entities: %d mounted: %d\n", named, mounted);
const mtnet::PlayerPoints &p = w.points();
std::printf("points: hp=%d/%d sp=%d/%d level=%d exp=%d/%d gold=%d\n", p.hp(), p.max_hp(), p.sp(),
p.max_sp(), p.level(), p.exp(), p.next_exp(), p.gold());
int inv_n = 0;
for (int c = 0; c < mtnet::INVENTORY_MAX_NUM; ++c) {
if (!w.inv_slot(c).empty()) {
++inv_n;
}
}
std::printf("inventory: %d non-empty slot(s)\n", inv_n);
std::printf("party: %zu member(s) friends: %zu\n", w.party_pids().size(), w.friends().size());
int skn = 0, qsn = 0;
for (int i = 0; i < mtnet::SKILL_MAX_NUM; ++i) {
if (w.skill_level(i) > 0) {
++skn;
}
}
for (int i = 0; i < mtnet::QUICKSLOT_MAX_NUM; ++i) {
if (w.quickslot(i).type != 0) {
++qsn;
}
}
std::printf("skills known: %d quickslots set: %d\n", skn, qsn);
const mtnet::GuildState &gld = w.guild();
if (gld.in_guild) {
std::printf("guild: \"%s\" lvl %d members %d/%d gold %u (%zu in list)\n",
gld.name.c_str(), gld.level, gld.member_count, gld.max_member_count, gld.gold,
w.guild_members().size());
} else {
std::printf("guild: (none)\n");
}
for (int i = 0; i < mtnet::SKILL_MAX_NUM && skn > 0; ++i) {
if (w.skill_level(i) > 0) {
std::printf(" skill %d: lvl %d master %d\n", i, w.skill_level(i), w.skill_master(i));
if (--skn == 0 || i > 60) {
break;
}
}
}
std::printf("\nRESULT: e2e OK — reached PHASE_GAME and pumped %.0fs\n", run_secs);
return 0;
}
+77
View File
@@ -0,0 +1,77 @@
// net_probe — connect to a Metin2 auth server, run the libsodium handshake and
// the CG_LOGIN3 exchange, print what happened. Purely diagnostic.
//
// net_probe <host> <port> <id> <pw> (defaults: 192.168.21.203 11000 admin 123456789)
#include "../src/net/auth_client.h"
#include <chrono>
#include <cstdio>
#include <string>
#include <thread>
int main(int argc, char **argv) {
std::string host = argc > 1 ? argv[1] : "192.168.21.203";
uint16_t port = argc > 2 ? static_cast<uint16_t>(std::stoi(argv[2])) : 11000;
std::string id = argc > 3 ? argv[3] : "admin";
std::string pw = argc > 4 ? argv[4] : "123456789";
std::printf("net_probe -> %s:%u id=%s\n", host.c_str(), port, id.c_str());
mtnet::AuthClient client(id, pw);
if (!client.connect(host, port)) {
std::printf("connect() failed: %s\n", client.last_error().c_str());
return 2;
}
using clock = std::chrono::steady_clock;
auto start = clock::now();
auto last_state = mtnet::NetStream::State::Offline;
bool reported_online = false, reported_cipher = false, reported_login = false;
while (std::chrono::duration_cast<std::chrono::seconds>(clock::now() - start).count() < 15) {
client.process();
if (client.state() != last_state) {
const char *s = client.state() == mtnet::NetStream::State::Connecting ? "Connecting"
: client.state() == mtnet::NetStream::State::Online ? "Online"
: "Offline";
std::printf("[state] %s\n", s);
last_state = client.state();
}
if (!reported_online && client.state() == mtnet::NetStream::State::Online) {
std::printf("[tcp] connected\n");
reported_online = true;
}
if (!reported_cipher && client.handshaked()) {
std::printf("[handshake] cipher activated (KX ok)\n");
reported_cipher = true;
}
if (!reported_login && client.sent_login()) {
std::printf("[auth] PHASE_AUTH reached, CG_LOGIN3 sent\n");
reported_login = true;
}
if (client.done()) {
break;
}
if (client.state() == mtnet::NetStream::State::Offline && reported_online) {
std::printf("[net] disconnected: %s\n", client.last_error().c_str());
break;
}
std::this_thread::sleep_for(std::chrono::milliseconds(20));
}
if (client.done() && client.success()) {
std::printf("RESULT: auth OK, login_key=0x%08X\n", client.login_key());
return 0;
}
if (client.done()) {
std::printf("RESULT: auth failed (%s)\n",
client.fail_reason().empty() ? client.last_error().c_str()
: client.fail_reason().c_str());
return 1;
}
std::printf("RESULT: timed out. last_error=%s online=%d cipher=%d login_sent=%d\n",
client.last_error().c_str(), (int)reported_online, (int)reported_cipher,
(int)reported_login);
return 3;
}
+94
View File
@@ -0,0 +1,94 @@
// packtool — cross-platform replacement for the fork's PackMaker.exe.
// packtool pack <folder> <out.epk> [--encrypt]
// packtool unpack <in.epk> <out_folder>
// packtool list <in.epk>
#include "../src/pack/eterpack.h"
#include <cstdio>
#include <cstring>
#include <filesystem>
#include <fstream>
#include <string>
#include <vector>
namespace fs = std::filesystem;
using namespace mtpack;
static std::vector<uint8_t> slurp(const fs::path &p) {
std::ifstream f(p, std::ios::binary);
return {std::istreambuf_iterator<char>(f), std::istreambuf_iterator<char>()};
}
static int do_pack(const char *folder, const char *out, bool enc) {
fs::path root(folder);
std::vector<InputFile> files;
for (auto &e : fs::recursive_directory_iterator(root)) {
if (!e.is_regular_file()) {
continue;
}
std::string rel = fs::relative(e.path(), root).generic_string();
files.push_back({rel, slurp(e.path())});
}
std::string err;
if (!write_pack(out, files, enc, &err)) {
std::fprintf(stderr, "pack failed: %s\n", err.c_str());
return 1;
}
std::printf("packed %zu files -> %s%s\n", files.size(), out, enc ? " (encrypted)" : "");
return 0;
}
static int do_unpack(const char *in, const char *out_folder) {
EterPack pk;
std::string err;
if (!pk.open(in, &err)) {
std::fprintf(stderr, "open failed: %s\n", err.c_str());
return 1;
}
for (const auto &name : pk.names()) {
std::vector<uint8_t> data;
if (!pk.read(name, data, &err)) {
std::fprintf(stderr, "read %s: %s\n", name.c_str(), err.c_str());
return 1;
}
fs::path dst = fs::path(out_folder) / name;
fs::create_directories(dst.parent_path());
std::ofstream f(dst, std::ios::binary);
f.write(reinterpret_cast<const char *>(data.data()), static_cast<std::streamsize>(data.size()));
}
std::printf("unpacked %zu files -> %s\n", pk.count(), out_folder);
return 0;
}
static int do_list(const char *in) {
EterPack pk;
std::string err;
if (!pk.open(in, &err)) {
std::fprintf(stderr, "open failed: %s\n", err.c_str());
return 1;
}
std::printf("%zu entries, name-field=%d\n", pk.count(), pk.name_field());
for (const auto &n : pk.names()) {
std::printf(" %s\n", n.c_str());
}
return 0;
}
int main(int argc, char **argv) {
if (argc >= 4 && std::strcmp(argv[1], "pack") == 0) {
bool enc = argc >= 5 && std::strcmp(argv[4], "--encrypt") == 0;
return do_pack(argv[2], argv[3], enc);
}
if (argc == 4 && std::strcmp(argv[1], "unpack") == 0) {
return do_unpack(argv[2], argv[3]);
}
if (argc == 3 && std::strcmp(argv[1], "list") == 0) {
return do_list(argv[2]);
}
std::fprintf(stderr,
"usage:\n"
" packtool pack <folder> <out.epk> [--encrypt]\n"
" packtool unpack <in.epk> <out_folder>\n"
" packtool list <in.epk>\n");
return 2;
}