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:
@@ -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
|
||||
@@ -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
|
||||
@@ -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/FarDistance(cm,A1 = 5000/20000 -> 50/200m
|
||||
// 太近,客户端 D3DFOG 实际按更大的世界尺度;乘一个系数放到远景轻霭区)。没给
|
||||
// 距离就退回 foglevel 启发式。参考端是线性远景雾 + 天空同色,不是浓雾。
|
||||
if (env.fog.enable) {
|
||||
e->set_fog_enabled(true);
|
||||
e->set_fog_light_color(rgba(env.fog.color));
|
||||
e->set_fog_mode(godot::Environment::FOG_MODE_DEPTH);
|
||||
float begin_m, end_m;
|
||||
if (env.fog.near_distance > 1.0f && env.fog.far_distance > env.fog.near_distance) {
|
||||
begin_m = env.fog.near_distance * (float)fmt::m2coord::CM_TO_M;
|
||||
end_m = env.fog.far_distance * (float)fmt::m2coord::CM_TO_M;
|
||||
// 客户端摄距比我们远:把近雾往后推一截,别糊住中景
|
||||
begin_m = std::max(begin_m, 120.0f);
|
||||
end_m = std::max(end_m, begin_m + 400.0f);
|
||||
} else {
|
||||
float fl = env.fog.fog_level > 0 ? float(env.fog.fog_level) : 4.0f;
|
||||
begin_m = std::clamp((11.0f - fl) * 45.0f, 60.0f, 500.0f);
|
||||
end_m = begin_m + 700.0f;
|
||||
}
|
||||
e->set_fog_depth_begin(begin_m);
|
||||
e->set_fog_depth_end(end_m);
|
||||
e->set_fog_depth_curve(0.5f);
|
||||
e->set_fog_density(0.0f);
|
||||
e->set_fog_sky_affect(0.0f); // 天空自己是渐变,不要被雾再洗一层
|
||||
e->set_fog_sun_scatter(0.05f);
|
||||
} else {
|
||||
e->set_fog_enabled(false);
|
||||
}
|
||||
|
||||
// 色调:参考端是 DX9 定功能、LDR、无 tonemap/HDR。用 LINEAR + 曝光 1 最贴近,
|
||||
// Filmic 会抬黑、降饱和 -> 画面发灰。轻微加饱和/对比补回胶片感。
|
||||
e->set_tonemapper(godot::Environment::TONE_MAPPER_LINEAR);
|
||||
e->set_tonemap_exposure(1.0f);
|
||||
e->set_adjustment_enabled(true);
|
||||
e->set_adjustment_saturation(1.12f);
|
||||
e->set_adjustment_contrast(1.05f);
|
||||
e->set_adjustment_brightness(1.0f);
|
||||
// glow:参考端没有 bloom。留极轻的,只有真过曝才溢。
|
||||
e->set_glow_enabled(true);
|
||||
e->set_glow_intensity(0.06f);
|
||||
e->set_glow_strength(0.7f);
|
||||
e->set_glow_bloom(0.0f);
|
||||
e->set_glow_hdr_bleed_threshold(1.6f);
|
||||
// SSAO:参考端把接触阴影烘进地形阴影贴图。这里用一点实时 SSAO 代替。
|
||||
e->set_ssao_enabled(true);
|
||||
e->set_ssao_radius(1.2f);
|
||||
e->set_ssao_intensity(0.9f);
|
||||
|
||||
out.world_env->set_environment(e);
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace mtgodot
|
||||
@@ -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
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
+1048
-92
File diff suppressed because it is too large
Load Diff
@@ -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> ¤t_skin_matrices() const { return current_skin; }
|
||||
|
||||
// CPU linear-blend skinning: rewrite ArrayMesh vertex regions from the
|
||||
// per-bone deformer matrices (gr2::sample_pose's `skin` output). Debug/
|
||||
@@ -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,
|
||||
|
||||
@@ -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):HeightMapShape3D,129×129,格距 = CELL_M(缩放承载)
|
||||
if (collision_enabled) {
|
||||
const int N = fmt::TerrainMesh::VERTS_XY; // 129
|
||||
PackedFloat32Array hd;
|
||||
hd.resize(N * N);
|
||||
for (int j = 0; j < N; ++j)
|
||||
for (int i = 0; i < N; ++i)
|
||||
hd[j * N + i] = tmesh.positions[(j * N + i) * 3 + 1]; // Godot Y
|
||||
Ref<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_conv(cm->m + Z-up->Y-up)
|
||||
Transform3D xform = place * make_conv(0.01f, false);
|
||||
|
||||
MeshInstance3D *mi = memnew(MeshInstance3D);
|
||||
mi->set_name(String(p->name.c_str()) + "_" + String::num_uint64(o.crc));
|
||||
mi->set_mesh(mesh);
|
||||
mi->set_transform(xform);
|
||||
// ShadowFlag: 客户端把 isShadowFlag 物体丢进动态阴影贴图(= 我们的实时投影);
|
||||
// 其余物体的阴影只存在于烘焙 shadowmap.dds。__static_shadows 可强制全部实时投。
|
||||
if (p->get("shadowflag") != "1" && !static_shadows)
|
||||
mi->set_cast_shadows_setting(GeometryInstance3D::SHADOW_CASTING_SETTING_OFF);
|
||||
// portal ids 先存进 meta(R1 不做室内裁剪)
|
||||
if (!o.portal_ids.empty()) {
|
||||
Array pids;
|
||||
for (int pid : o.portal_ids)
|
||||
pids.push_back(pid);
|
||||
mi->set_meta("portal_ids", pids);
|
||||
}
|
||||
// .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
|
||||
@@ -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 §8(Metin2World API 契约)/ §9-W1。
|
||||
//
|
||||
// Metin2World (Node3D)
|
||||
// └─ Terrain_000000 (MeshInstance3D) …每区块一个
|
||||
//
|
||||
// 后续阶段往这里加 splat 材质(W2)、静态对象(W3)、树(W4)、环境(W5)…
|
||||
class Metin2World : public godot::Node3D {
|
||||
GDCLASS(Metin2World, godot::Node3D)
|
||||
|
||||
public:
|
||||
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_path(asset_index.txt)。
|
||||
// 打进 PCK 后移动端 build_or_load() 直接装载,不再 std::filesystem 扫盘。
|
||||
bool bake_asset_index(const godot::String &out_path);
|
||||
|
||||
protected:
|
||||
static void _bind_methods();
|
||||
|
||||
private:
|
||||
godot::String assets_root;
|
||||
godot::String map_path = "OutdoorA1/metin2_map_a1";
|
||||
int load_radius = -1; // <0 = 全图
|
||||
int focus_tx = 0, focus_ty = 0;
|
||||
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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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" {
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 Image,resize 到 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:185:u' = (TexCoordBase*UScale)*vtx_cm + UOffset,
|
||||
// TexCoordBase = 1/(PATCH_XSIZE*CELLSCALE) = 1/3200;区块归一化 UV -> 平铺频率 = 8*Scale。
|
||||
float us = tl.u_scale > 0.01f ? tl.u_scale : 1.0f;
|
||||
float vs = tl.v_scale > 0.01f ? tl.v_scale : 1.0f;
|
||||
uvparm[i] = Color(8.0f * us, -8.0f * vs, tl.u_offset, -tl.v_offset);
|
||||
}
|
||||
imgs.push_back(img.is_valid() ? img : fallback);
|
||||
}
|
||||
|
||||
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
|
||||
@@ -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:逐图层按权重 mix,UV 按 TextureLayer.u_scale/offset 平铺
|
||||
// - shadowmap.dds 作为 albedo 乘法项(有则)
|
||||
// 光照交给 Godot 内置 DirectionalLight(W5 由 .msenv 驱动)。
|
||||
godot::Ref<godot::ShaderMaterial> build_chunk_terrain_material(
|
||||
const fmt::SplatSet &splat,
|
||||
const fmt::TextureSet &tset,
|
||||
const fmt::AssetResolver &res,
|
||||
const godot::String &shadowmap_path);
|
||||
|
||||
// 清理 function-static 的 terrain shader(退出时调,避免 "shader never freed")。
|
||||
void cleanup_terrain_shader();
|
||||
|
||||
} // namespace mtgodot
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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) 对应的区块本地 cm(texel = 1 格 = CELLSCALE)
|
||||
double lx = tex_x * double(fmt::m2coord::CELLSCALE);
|
||||
double ly = tex_y * double(fmt::m2coord::CELLSCALE);
|
||||
double th = fmt::terrain_height_at(hm, lx, ly, height_scale); // cm
|
||||
double depth_cm = water_h_cm - th;
|
||||
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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user