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
+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