Files
mtgodot-poc/extension/src/metin2_model.cpp
T
shenandClaude Sonnet 5 47baf6c0c6 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
2026-08-31 20:02:12 +09:00

1533 lines
50 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#include "metin2_model.h"
#include "asset_io.h"
#include "dxt.h"
#include "gr2_bridge.h"
#include "m2_material.h"
#include "texture_util.h"
#include <godot_cpp/classes/array_mesh.hpp>
#include <godot_cpp/classes/base_material3d.hpp>
#include <godot_cpp/classes/camera3d.hpp>
#include <godot_cpp/classes/dir_access.hpp>
#include <godot_cpp/classes/file_access.hpp>
#include <godot_cpp/classes/image.hpp>
#include <godot_cpp/classes/image_texture.hpp>
#include <godot_cpp/classes/mesh_instance3d.hpp>
#include <godot_cpp/classes/os.hpp>
#include <godot_cpp/classes/shader_material.hpp>
#include <godot_cpp/classes/skeleton3d.hpp>
#include <godot_cpp/classes/skin.hpp>
#include <godot_cpp/classes/standard_material3d.hpp>
#include <godot_cpp/classes/viewport.hpp>
#include <godot_cpp/core/class_db.hpp>
#include <godot_cpp/variant/dictionary.hpp>
#include <godot_cpp/variant/packed_byte_array.hpp>
#include <godot_cpp/variant/utility_functions.hpp>
#include <msm.h>
#include <cmath>
#include <string>
using namespace godot;
namespace mtgodot {
static mtgodot::BlendMode guess_blend(const godot::String &surface_name); // defined below
static const gr2::MaterialInfo *match_material(const std::vector<gr2::MaterialInfo> &mats,
const godot::String &want_base);
static mtgodot::BlendMode decide_blend(const gr2::MaterialInfo *m, const godot::String &sname);
static bool decide_two_sided(const gr2::MaterialInfo *m, const godot::String &sname);
Metin2Model::Metin2Model() {
file = std::make_shared<std::optional<gr2::File>>();
lod_dist.push_back(18.0f);
lod_dist.push_back(42.0f);
lod_dist.push_back(90.0f);
}
Metin2Model::~Metin2Model() = default;
void Metin2Model::_bind_methods() {
ClassDB::bind_method(D_METHOD("set_gr2_path", "path"), &Metin2Model::set_gr2_path);
ClassDB::bind_method(D_METHOD("get_gr2_path"), &Metin2Model::get_gr2_path);
ClassDB::bind_method(D_METHOD("set_texture_dir", "path"), &Metin2Model::set_texture_dir);
ClassDB::bind_method(D_METHOD("get_texture_dir"), &Metin2Model::get_texture_dir);
ClassDB::bind_method(D_METHOD("set_unit_scale", "s"), &Metin2Model::set_unit_scale);
ClassDB::bind_method(D_METHOD("get_unit_scale"), &Metin2Model::get_unit_scale);
ClassDB::bind_method(D_METHOD("set_flip_z", "v"), &Metin2Model::set_flip_z);
ClassDB::bind_method(D_METHOD("get_flip_z"), &Metin2Model::get_flip_z);
ClassDB::bind_method(D_METHOD("set_flip_winding", "v"), &Metin2Model::set_flip_winding);
ClassDB::bind_method(D_METHOD("get_flip_winding"), &Metin2Model::get_flip_winding);
ClassDB::bind_method(D_METHOD("set_material_mode", "m"), &Metin2Model::set_material_mode);
ClassDB::bind_method(D_METHOD("get_material_mode"), &Metin2Model::get_material_mode);
ClassDB::bind_method(D_METHOD("set_use_gr2_materials", "v"), &Metin2Model::set_use_gr2_materials);
ClassDB::bind_method(D_METHOD("get_use_gr2_materials"), &Metin2Model::get_use_gr2_materials);
ClassDB::bind_method(D_METHOD("set_specular_power", "p"), &Metin2Model::set_specular_power);
ClassDB::bind_method(D_METHOD("get_specular_power"), &Metin2Model::get_specular_power);
ClassDB::bind_method(D_METHOD("set_surface_texture", "surface", "path"), &Metin2Model::set_surface_texture);
ClassDB::bind_method(D_METHOD("reload"), &Metin2Model::reload);
ClassDB::bind_method(D_METHOD("get_info"), &Metin2Model::get_info);
ClassDB::bind_method(D_METHOD("get_hair_options"), &Metin2Model::get_hair_options);
ClassDB::bind_method(D_METHOD("set_hair_gr2", "p"), &Metin2Model::set_hair_gr2);
ClassDB::bind_method(D_METHOD("get_hair_gr2"), &Metin2Model::get_hair_gr2);
ClassDB::bind_method(D_METHOD("set_hair_skin", "p"), &Metin2Model::set_hair_skin);
ClassDB::bind_method(D_METHOD("get_hair_skin"), &Metin2Model::get_hair_skin);
ClassDB::bind_method(D_METHOD("set_weapon_gr2", "p"), &Metin2Model::set_weapon_gr2);
ClassDB::bind_method(D_METHOD("get_weapon_gr2"), &Metin2Model::get_weapon_gr2);
ClassDB::bind_method(D_METHOD("set_weapon_bone", "b"), &Metin2Model::set_weapon_bone);
ClassDB::bind_method(D_METHOD("get_weapon_bone"), &Metin2Model::get_weapon_bone);
ClassDB::bind_method(D_METHOD("set_shield_gr2", "p"), &Metin2Model::set_shield_gr2);
ClassDB::bind_method(D_METHOD("get_shield_gr2"), &Metin2Model::get_shield_gr2);
ClassDB::bind_method(D_METHOD("set_shield_bone", "b"), &Metin2Model::set_shield_bone);
ClassDB::bind_method(D_METHOD("get_shield_bone"), &Metin2Model::get_shield_bone);
ClassDB::bind_method(D_METHOD("probe_gr2", "path"), &Metin2Model::probe_gr2);
ADD_PROPERTY(PropertyInfo(Variant::STRING, "gr2_path", PROPERTY_HINT_GLOBAL_FILE, "*.gr2,*.msm"),
"set_gr2_path", "get_gr2_path");
ADD_PROPERTY(PropertyInfo(Variant::STRING, "texture_dir", PROPERTY_HINT_GLOBAL_DIR),
"set_texture_dir", "get_texture_dir");
ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "unit_scale", PROPERTY_HINT_RANGE, "0.0001,1,0.0001"),
"set_unit_scale", "get_unit_scale");
ADD_PROPERTY(PropertyInfo(Variant::BOOL, "flip_z"), "set_flip_z", "get_flip_z");
ADD_PROPERTY(PropertyInfo(Variant::BOOL, "flip_winding"), "set_flip_winding", "get_flip_winding");
ADD_PROPERTY(PropertyInfo(Variant::STRING, "material_mode", PROPERTY_HINT_ENUM, "metin2,standard"),
"set_material_mode", "get_material_mode");
ADD_PROPERTY(PropertyInfo(Variant::BOOL, "use_gr2_materials"),
"set_use_gr2_materials", "get_use_gr2_materials");
ClassDB::bind_method(D_METHOD("set_lod_enabled", "v"), &Metin2Model::set_lod_enabled);
ClassDB::bind_method(D_METHOD("get_lod_enabled"), &Metin2Model::get_lod_enabled);
ClassDB::bind_method(D_METHOD("set_lod_distances", "d"), &Metin2Model::set_lod_distances);
ClassDB::bind_method(D_METHOD("get_lod_distances"), &Metin2Model::get_lod_distances);
ClassDB::bind_method(D_METHOD("get_lod_level"), &Metin2Model::get_lod_level);
ADD_PROPERTY(PropertyInfo(Variant::BOOL, "lod_enabled"), "set_lod_enabled", "get_lod_enabled");
ADD_PROPERTY(PropertyInfo(Variant::PACKED_FLOAT32_ARRAY, "lod_distances"),
"set_lod_distances", "get_lod_distances");
ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "specular_power", PROPERTY_HINT_RANGE, "0,4,0.01"),
"set_specular_power", "get_specular_power");
ADD_PROPERTY(PropertyInfo(Variant::STRING, "hair_gr2", PROPERTY_HINT_GLOBAL_FILE, "*.gr2"),
"set_hair_gr2", "get_hair_gr2");
ADD_PROPERTY(PropertyInfo(Variant::STRING, "hair_skin", PROPERTY_HINT_GLOBAL_FILE, "*.dds"),
"set_hair_skin", "get_hair_skin");
ADD_PROPERTY(PropertyInfo(Variant::STRING, "weapon_gr2", PROPERTY_HINT_GLOBAL_FILE, "*.gr2"),
"set_weapon_gr2", "get_weapon_gr2");
ADD_PROPERTY(PropertyInfo(Variant::STRING, "weapon_bone"), "set_weapon_bone", "get_weapon_bone");
ADD_PROPERTY(PropertyInfo(Variant::STRING, "shield_gr2", PROPERTY_HINT_GLOBAL_FILE, "*.gr2"),
"set_shield_gr2", "get_shield_gr2");
ADD_PROPERTY(PropertyInfo(Variant::STRING, "shield_bone"), "set_shield_bone", "get_shield_bone");
}
void Metin2Model::set_material_mode(const String &m) {
material_mode = m;
if (is_inside_tree()) {
reload();
}
}
void Metin2Model::set_specular_power(double p) {
specular_power = p < 0.0 ? 0.0 : p;
if (is_inside_tree() && mi) {
_apply_materials();
}
}
void Metin2Model::set_use_gr2_materials(bool v) {
use_gr2_materials = v;
if (is_inside_tree()) {
_apply_materials();
}
}
void Metin2Model::set_surface_texture(int surface, const String &path) {
if (surface < 0) {
return;
}
while (surface_tex_override.size() <= surface) {
surface_tex_override.push_back(String());
}
surface_tex_override[surface] = path;
if (is_inside_tree()) {
_apply_materials();
}
}
void Metin2Model::set_gr2_path(const String &p) {
gr2_path = p;
if (is_inside_tree()) {
reload();
}
}
void Metin2Model::set_texture_dir(const String &p) {
texture_dir = p;
if (is_inside_tree()) {
reload();
}
}
void Metin2Model::set_unit_scale(double s) {
unit_scale = s;
if (is_inside_tree()) {
reload();
}
}
void Metin2Model::set_flip_z(bool v) {
flip_z = v;
if (is_inside_tree()) {
reload();
}
}
void Metin2Model::set_flip_winding(bool v) {
flip_winding = v;
if (is_inside_tree()) {
reload();
}
}
void Metin2Model::_ready() {
set_process(true);
if (!gr2_path.is_empty()) {
reload();
}
}
void Metin2Model::set_lod_enabled(bool v) {
lod_enabled = v;
if (!v && lod_level != 0) {
_set_lod(0);
}
}
void Metin2Model::set_lod_distances(const PackedFloat32Array &d) {
lod_dist = d;
}
const gr2::FileInfo *Metin2Model::active_fi() const {
if (lod_level >= 1 && lod_level <= (int)lod_files.size() && lod_files[lod_level - 1] &&
*lod_files[lod_level - 1]) {
return &(**lod_files[lod_level - 1]).file_info();
}
return (file && *file) ? &(*file)->file_info() : nullptr;
}
void Metin2Model::_load_lods(const String &loaded_spec) {
lod_files.clear();
lod_parts.clear();
String stem = loaded_spec.get_basename(); // ".../warrior_cheongrin"
for (int i = 1; i <= 3; ++i) {
String lp = stem + vformat("_lod_%02d.gr2", i);
if (!FileAccess::file_exists(lp)) {
break;
}
gr2::LoadError e;
auto f = mtgodot::gr2_from_file(lp, &e);
if (!f) {
UtilityFunctions::push_warning(String("[Metin2Model] lod load failed: ") + lp);
break;
}
auto slot = std::make_shared<std::optional<gr2::File>>(std::move(f));
lod_parts.push_back(mtgodot::build_parts((**slot).file_info()));
lod_files.push_back(std::move(slot));
}
if (!lod_files.empty()) {
UtilityFunctions::print(vformat("[Metin2Model] LOD: %d level(s) loaded", (int)lod_files.size()));
}
}
namespace {
constexpr double kLodFadeDur = 0.18;
void set_lod_fade(const std::vector<Ref<Material>> &mats, float v) {
for (const Ref<Material> &m : mats) {
Ref<ShaderMaterial> sm = m;
if (sm.is_valid()) {
sm->set_shader_parameter("lod_fade", v);
}
}
}
} // namespace
void Metin2Model::_end_lod_fade() {
if (lod_prev_mi) {
remove_child(lod_prev_mi);
lod_prev_mi->queue_free();
lod_prev_mi = nullptr;
}
lod_fade_t = -1.0;
set_lod_fade(surf_mats, 1.0f);
}
void Metin2Model::_set_lod(int n) {
n = n < 0 ? 0 : (n > (int)lod_files.size() ? (int)lod_files.size() : n);
if (n == lod_level || !mi) {
return;
}
// snapshot the outgoing level into a frozen ghost that fades out
_end_lod_fade();
if (mi->get_mesh().is_valid() && material_mode != String("standard")) {
lod_prev_mi = memnew(MeshInstance3D);
lod_prev_mi->set_name("LodGhost");
lod_prev_mi->set_mesh(mi->get_mesh());
add_child(lod_prev_mi);
lod_prev_mi->set_transform(mi->get_transform());
std::vector<Ref<Material>> ghost_mats;
for (int s = 0; s < (int)surf_mats.size(); ++s) {
Ref<Material> src = surf_mats[s];
Ref<Material> dup;
if (src.is_valid()) {
dup = src->duplicate();
}
lod_prev_mi->set_surface_override_material(s, dup);
ghost_mats.push_back(dup);
}
set_lod_fade(ghost_mats, 1.0f);
lod_ghost_mats = std::move(ghost_mats);
lod_fade_t = 0.0;
}
lod_level = n;
parts = (n == 0) ? base_parts : lod_parts[n - 1];
const gr2::FileInfo *afi = active_fi();
if (!afi) {
_end_lod_fade();
return;
}
if (cpu_mesh.is_valid()) {
AABB b;
cpu_mesh = build_mesh(*afi, parts, flip_winding, b);
mi->set_mesh(cpu_mesh);
} else {
AABB b;
Ref<ArrayMesh> m = build_mesh(*afi, parts, flip_winding, b);
if (m->get_surface_count() > 0) {
mi->set_mesh(m);
}
}
_apply_materials();
if (cpu_mesh.is_valid() && !current_skin.empty()) {
cpu_skin(current_skin);
}
if (lod_fade_t >= 0.0) {
set_lod_fade(surf_mats, 0.0f); // new level starts invisible, fades in
}
}
void Metin2Model::_process(double delta) {
// LOD crossfade progression
if (lod_fade_t >= 0.0) {
lod_fade_t += delta;
float k = (float)std::min(1.0, lod_fade_t / kLodFadeDur);
set_lod_fade(surf_mats, k);
set_lod_fade(lod_ghost_mats, 1.0f - k);
if (k >= 1.0f) {
_end_lod_fade();
lod_ghost_mats.clear();
}
}
if (!lod_enabled || lod_files.empty() || !mi || lod_dist.is_empty()) {
return;
}
Viewport *vp = get_viewport();
Camera3D *cam = vp ? vp->get_camera_3d() : nullptr;
if (!cam) {
return;
}
float d = get_global_position().distance_to(cam->get_global_position());
// hysteresis: step up at lod_dist[i], step down only 15% closer
int lv = 0;
for (int i = 0; i < (int)lod_dist.size() && i < 3; ++i) {
float up = lod_dist[i];
float down = up * 0.85f;
if (d > up) {
lv = i + 1;
} else if (d > down && lv == i && lod_level >= i + 1) {
lv = i + 1; // stay in the higher LOD across the dead-band
}
}
_set_lod(lv);
}
void Metin2Model::_clear_children() {
skel = nullptr;
mi = nullptr;
weapon_mi = nullptr;
weapon_bone_idx = -1;
shield_mi = nullptr;
shield_bone_idx = -1;
TypedArray<Node> kids = get_children();
for (int i = 0; i < kids.size(); ++i) {
Node *n = Object::cast_to<Node>(kids[i]);
if (n) {
remove_child(n);
n->queue_free();
}
}
}
void Metin2Model::reload() {
_clear_children();
*file = std::nullopt;
last_info = "";
resolved_gr2_dir = "";
hair_options.clear();
if (hair_file)
*hair_file = std::nullopt;
hair_parts.clear();
hair_bone_remap.clear();
hair_mat.unref();
hair_tex.unref();
gpu_base_surf = 0;
if (weapon_file)
*weapon_file = std::nullopt;
if (shield_file)
*shield_file = std::nullopt;
materials.clear();
parts.clear();
base_parts.clear();
lod_files.clear();
lod_parts.clear();
lod_level = 0;
surf_mats.clear();
cpu_mesh.unref();
gpu_skin_active = false;
bones_img.unref();
bones_tex.unref();
current_skin.clear();
if (gr2_path.is_empty()) {
return;
}
// .msm -> resolve BaseModelFileName; stash the hair catalog for get_hair_options().
String gr2_spec = gr2_path;
if (gr2_path.to_lower().ends_with(".msm")) {
fmt::Msm ms;
std::string e;
if (fmt::parse_msm_file(std::string(gr2_path.utf8().get_data()), ms, &e)) {
gr2_spec = resolve_rel_gr2(gr2_path, String(ms.base_model_gr2.c_str()));
const String hp = String(ms.hair_path.c_str());
for (const fmt::HairEntry &h : ms.hairs) {
Dictionary d;
d["index"] = h.index;
d["model"] = resolve_rel_gr2(gr2_path, hp + String(h.model.c_str()));
// SourceSkin / TargetSkin resolved to absolute (§2.4). TargetSkin is
// relative to PathName; SourceSkin too (the gr2's own texture).
const String ss(h.source_skin.c_str()), ts(h.target_skin.c_str());
d["source_skin"] = ss.is_empty() ? String() : resolve_rel_gr2(gr2_path, hp + ss);
d["target_skin"] = ts.is_empty() ? String() : resolve_rel_gr2(gr2_path, hp + ts);
hair_options.push_back(d);
}
UtilityFunctions::print(vformat("[Metin2Model] .msm -> %s hairs=%d",
gr2_spec, (int)hair_options.size()));
} else {
UtilityFunctions::push_error(String("[Metin2Model] .msm: ") + String(e.c_str()));
return;
}
}
gr2::LoadError err;
auto loaded = mtgodot::gr2_from_file(gr2_spec, &err);
if (!loaded) {
UtilityFunctions::push_error(String("[Metin2Model] gr2 load failed [") +
String(err.stage.c_str()) + "]: " + String(err.message.c_str()) + " (" + gr2_spec + ")");
return;
}
*file = std::move(loaded);
resolved_gr2_dir = gr2_spec.get_base_dir();
const gr2::FileInfo &fi = (*file)->file_info();
set_transform(make_conv((float)unit_scale, flip_z));
// Skeleton (optional).
const gr2::Skeleton *sk = fi.skeletons.empty() ? nullptr : &fi.skeletons[0];
if (sk) {
std::vector<gr2::Mat4> bind_world;
gr2::bind_pose(*sk, bind_world, current_skin);
skel = build_skeleton(*sk);
if (skel) {
add_child(skel);
skel->set_owner(get_owner() ? get_owner() : this);
}
}
// Mesh — one surface per render part (gr2 mesh split by tri_group.material_index).
parts = mtgodot::build_parts(fi);
base_parts = parts;
AABB bounds;
Ref<ArrayMesh> mesh = build_mesh(fi, parts, flip_winding, bounds);
if (mesh->get_surface_count() > 0) {
mi = memnew(MeshInstance3D);
mi->set_name("MeshInstance3D");
mi->set_mesh(mesh);
add_child(mi);
mi->set_owner(get_owner() ? get_owner() : this);
if (skel && sk) {
mi->set_skeleton_path(mi->get_path_to(skel));
if (OS::get_singleton()->get_environment("MTGODOT_AUTOSKIN") == "1") {
// let Godot derive the skin from skeleton rest poses
} else {
mi->set_skin(build_skin(*sk));
}
}
mi->set_custom_aabb(bounds.grow(0.01));
}
materials = gr2::dump_materials(**file);
_apply_materials();
int vtot = 0;
for (const auto &m : fi.meshes) {
vtot += (int)m.vertices.size();
}
{
int with_tex = 0;
for (const auto &mm : materials) {
if (!mm.diffuse_texture.empty()) {
++with_tex;
}
}
UtilityFunctions::print(vformat("[Metin2Model] gr2 materials: %d (%d with diffuse name)",
(int)materials.size(), with_tex));
}
last_info = vformat("gr2 v%d | bones=%d meshes=%d surfaces=%d verts=%d | bounds=%s",
(int)(*file)->format_version(),
sk ? (int)sk->bones.size() : 0,
(int)fi.meshes.size(),
mesh->get_surface_count(),
vtot,
String(bounds));
UtilityFunctions::print("[Metin2Model] ", last_info);
_load_lods(gr2_spec);
if (!hair_gr2.is_empty())
_load_hair();
if (!weapon_gr2.is_empty())
_load_weapon();
if (!shield_gr2.is_empty())
_load_shield();
}
void Metin2Model::_load_hair() {
hair_parts.clear();
hair_bone_remap.clear();
hair_mat.unref();
hair_tex.unref();
gpu_base_surf = 0;
if (hair_gr2.is_empty() || !file || !*file)
return;
const gr2::FileInfo &base_fi = (*file)->file_info();
if (base_fi.skeletons.empty())
return;
if (!hair_file)
hair_file = std::make_shared<std::optional<gr2::File>>();
gr2::LoadError e;
auto h = mtgodot::gr2_from_file(hair_gr2, &e);
if (!h) {
UtilityFunctions::push_warning(String("[Metin2Model] hair load failed: ") + e.message.c_str());
return;
}
*hair_file = std::move(h);
const gr2::FileInfo &hfi = (**hair_file).file_info();
if (hfi.skeletons.empty())
return;
// hair 骨骼名 -> base 骨骼索引
const auto &bbones = base_fi.skeletons[0].bones;
const auto &hbones = hfi.skeletons[0].bones;
hair_bone_remap.assign(hbones.size(), -1);
for (size_t i = 0; i < hbones.size(); ++i)
for (size_t j = 0; j < bbones.size(); ++j)
if (hbones[i].name == bbones[j].name) {
hair_bone_remap[i] = (int)j;
break;
}
hair_parts = mtgodot::build_parts(hfi);
// hair 贴图:同名 .ddsalpha-scissor 镂空
Ref<StandardMaterial3D> hm;
hm.instantiate();
hm->set_roughness(1.0f);
hm->set_transparency(StandardMaterial3D::TRANSPARENCY_ALPHA_SCISSOR);
hm->set_alpha_scissor_threshold(0.5f);
hm->set_cull_mode(StandardMaterial3D::CULL_DISABLED);
// SourceSkin (gr2 sibling .dds) -> TargetSkin override (§2.4)
String hdds = hair_skin.is_empty() ? (hair_gr2.get_basename() + ".dds") : hair_skin;
Ref<ImageTexture> ht = _load_dds(hdds);
if (ht.is_null() && !hair_skin.is_empty()) {
UtilityFunctions::push_warning(String("[Metin2Model] hair_skin not found: ") + hair_skin);
ht = _load_dds(hair_gr2.get_basename() + ".dds"); // fall back to SourceSkin
}
if (ht.is_valid())
hm->set_texture(BaseMaterial3D::TEXTURE_ALBEDO, ht);
else
hm->set_albedo(Color(0.18f, 0.12f, 0.08f));
hair_mat = hm;
hair_tex = ht; // reused by the GPU-skin path (§2.2)
int matched = 0;
for (int r : hair_bone_remap)
if (r >= 0)
++matched;
UtilityFunctions::print(vformat("[Metin2Model] hair: %s %d/%d bones matched, %d parts",
hair_gr2.get_file(), matched, (int)hbones.size(), (int)hair_parts.size()));
if (has_cpu_skin_mesh() && !current_skin.empty())
cpu_skin(current_skin);
}
void Metin2Model::set_hair_gr2(const String &p) {
if (p == hair_gr2)
return;
hair_gr2 = p;
if (file && *file)
_load_hair();
}
void Metin2Model::set_hair_skin(const String &p) {
if (p == hair_skin)
return;
hair_skin = p;
if (file && *file && !hair_gr2.is_empty())
_load_hair();
}
void Metin2Model::set_weapon_gr2(const String &p) {
if (p == weapon_gr2)
return;
weapon_gr2 = p;
if (file && *file)
_load_weapon();
}
void Metin2Model::set_weapon_bone(const String &b) {
if (b == weapon_bone)
return;
weapon_bone = b;
if (file && *file)
_load_weapon();
}
void Metin2Model::set_shield_gr2(const String &p) {
if (p == shield_gr2)
return;
shield_gr2 = p;
if (file && *file)
_load_shield();
}
void Metin2Model::set_shield_bone(const String &b) {
if (b == shield_bone)
return;
shield_bone = b;
if (file && *file)
_load_shield();
}
void Metin2Model::_load_weapon() {
_load_attach(weapon_gr2, weapon_bone, weapon_file, weapon_mi, weapon_bone_idx, weapon_pre,
"Weapon", "weapon");
}
void Metin2Model::_load_shield() {
_load_attach(shield_gr2, shield_bone, shield_file, shield_mi, shield_bone_idx, shield_pre,
"Shield", "shield");
}
void Metin2Model::_load_attach(const String &gr2_rel, const String &bone_name,
std::shared_ptr<std::optional<gr2::File>> &slot_file, MeshInstance3D *&slot_mi,
int &slot_bone_idx, gr2::Mat4 &slot_pre, const char *node_name, const char *label) {
if (slot_mi) {
remove_child(slot_mi);
slot_mi->queue_free();
slot_mi = nullptr;
}
slot_bone_idx = -1;
slot_pre = gr2::Mat4{ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 };
if (gr2_rel.is_empty() || !file || !*file)
return;
const gr2::FileInfo &base_fi = (*file)->file_info();
if (base_fi.skeletons.empty())
return;
// attach bone: name -> base skeleton index (client: equip_right_hand / _left_)
const auto &bbones = base_fi.skeletons[0].bones;
std::string want(bone_name.utf8().get_data());
for (size_t j = 0; j < bbones.size(); ++j)
if (bbones[j].name == want) {
slot_bone_idx = (int)j;
break;
}
if (slot_bone_idx < 0) {
UtilityFunctions::push_warning(String("[Metin2Model] ") + label + " bone not found: " +
bone_name);
return;
}
String wpath = gr2_rel;
if (!FileAccess::file_exists(wpath))
wpath = resolve_rel_gr2(
resolved_gr2_dir.is_empty() ? gr2_path : resolved_gr2_dir.path_join("x"), gr2_rel);
gr2::LoadError e;
auto w = mtgodot::gr2_from_file(wpath, &e);
if (!w) {
UtilityFunctions::push_warning(String("[Metin2Model] ") + label + " load failed: " +
String(e.message.c_str()) + " (" + wpath + ")");
return;
}
if (!slot_file)
slot_file = std::make_shared<std::optional<gr2::File>>();
*slot_file = std::move(w);
const gr2::FileInfo &wfi = (**slot_file).file_info();
// Grip pre-transform, mirroring the client's weapon composite:
// weaponComposite[0] = weaponInverseWorld[0] · weaponLocal[0]
// weaponMeshWorld = weaponComposite[0] · pAttachBoneMatrix(handWorld) · charWorld
// (EterGrnLib/ModelInstanceUpdate.cpp:171 + GetBoneMatrixPointer).
// Most Metin2 weapons are authored directly in hand space so this pre is ~I;
// a few non-standard files carry a huge bone offset that would fling the mesh
// metres away — clamp those out and fall back to the raw hand pose.
if (!wfi.skeletons.empty() && !wfi.skeletons[0].bones.empty()) {
const gr2::Bone &wb0 = wfi.skeletons[0].bones[0];
gr2::Mat4 pre = mtgodot::mul4x3(wb0.inverse_world, wb0.local_transform);
const float tx = pre[12], ty = pre[13], tz = pre[14];
if (tx * tx + ty * ty + tz * tz < 30.0f * 30.0f) // < 30 cm
slot_pre = pre;
}
std::vector<mtgodot::RenderPart> wparts = mtgodot::build_parts(wfi);
AABB wb;
Ref<ArrayMesh> wmesh = mtgodot::build_mesh(wfi, wparts, flip_winding, wb);
if (wmesh.is_null() || wmesh->get_surface_count() == 0)
return;
// textures: gr2 material binding -> sibling .dds by basename
String wdir = wpath.get_base_dir();
PackedStringArray wdds;
if (Ref<DirAccess> da = DirAccess::open(wdir); da.is_valid()) {
da->list_dir_begin();
for (String f = da->get_next(); !f.is_empty(); f = da->get_next())
if (!da->current_is_dir() && f.to_lower().ends_with(".dds"))
wdds.push_back(f);
da->list_dir_end();
}
auto find_wdds = [&](const String &want_base) -> Ref<ImageTexture> {
for (int i = 0; i < wdds.size(); ++i)
if (wdds[i].get_basename().to_lower() == want_base)
return _load_dds(wdir.path_join(wdds[i]));
return Ref<ImageTexture>();
};
slot_mi = memnew(MeshInstance3D);
slot_mi->set_name(node_name);
slot_mi->set_mesh(wmesh);
add_child(slot_mi);
std::vector<gr2::MaterialInfo> wmaterials = gr2::dump_materials(**slot_file);
int wtextured = 0;
for (int s = 0; s < wmesh->get_surface_count(); ++s) {
String want_base;
if (s < (int)wparts.size()) {
const gr2::Mesh &gm = wfi.meshes[wparts[s].mesh];
if (wparts[s].mat_index >= 0 && wparts[s].mat_index < (int)gm.material_textures.size())
want_base = String(gm.material_textures[wparts[s].mat_index].c_str())
.replace("\\", "/")
.get_file()
.get_basename()
.to_lower();
}
Ref<ImageTexture> tex = find_wdds(want_base);
if (tex.is_valid())
++wtextured;
Ref<Material> mat;
if (material_mode == String("standard")) {
Ref<StandardMaterial3D> sm;
sm.instantiate();
if (tex.is_valid())
sm->set_texture(BaseMaterial3D::TEXTURE_ALBEDO, tex);
mat = sm;
} else {
const String wsn = wmesh->surface_get_name(s);
const gr2::MaterialInfo *wm = match_material(wmaterials, want_base);
mtgodot::MaterialDesc md;
md.albedo = tex;
md.blend = decide_blend(wm, wsn);
md.two_sided = decide_two_sided(wm, wsn);
mat = mtgodot::make_material(md);
}
slot_mi->set_surface_override_material(s, mat);
}
// place immediately at bind pose so a static (non-animated) model still holds it
std::vector<gr2::Mat4> bw, bs;
gr2::bind_pose(base_fi.skeletons[0], bw, bs);
update_weapon_pose(bw);
UtilityFunctions::print(vformat("[Metin2Model] %s: %s -> bone[%d] %s, %d surf (%d textured)",
label, wpath.get_file(), slot_bone_idx, bone_name, wmesh->get_surface_count(),
wtextured));
}
void Metin2Model::update_weapon_pose(const std::vector<gr2::Mat4> &world_pose) {
// weapon local = gripPre · handWorld (client: weaponBone[0] composed onto the
// parent attach-bone matrix). gripPre is identity for near-origin weapons, so
// this is backward-compatible with the ones that already looked right.
if (weapon_mi && weapon_bone_idx >= 0 && weapon_bone_idx < (int)world_pose.size())
weapon_mi->set_transform(mtgodot::gr2_to_godot(
mtgodot::mul4x3(weapon_pre, world_pose[weapon_bone_idx])));
if (shield_mi && shield_bone_idx >= 0 && shield_bone_idx < (int)world_pose.size())
shield_mi->set_transform(mtgodot::gr2_to_godot(
mtgodot::mul4x3(shield_pre, world_pose[shield_bone_idx])));
}
String Metin2Model::_guess_texture_dir() const {
if (!texture_dir.is_empty()) {
return texture_dir;
}
return resolved_gr2_dir.is_empty() ? gr2_path.get_base_dir() : resolved_gr2_dir;
}
// Shared sphere map for §2.7 specular. Client sphere index 0 == the global
// "d:/ymir work/special/spheremap.jpg"; in the loose-file packs that lands at
// "<pack>/ymir work/special/spheremap.jpg" (typically the ETC pack). Walk up from
// the model dir and probe each level + its immediate subdirs. .jpg decodes via
// Godot core (Image::load), no DDS path needed.
Ref<godot::Texture2D> Metin2Model::_load_sphere_map() {
if (sphere_map.is_valid()) {
return sphere_map;
}
static const char *kTail = "ymir work/special/spheremap.jpg";
String hit;
String dir = _guess_texture_dir();
for (int up = 0; up < 8 && !dir.is_empty() && dir != "/" && hit.is_empty(); ++up) {
String direct = dir.path_join(kTail);
if (FileAccess::file_exists(direct)) {
hit = direct;
break;
}
Ref<DirAccess> da = DirAccess::open(dir);
if (da.is_valid()) {
da->list_dir_begin();
for (String f = da->get_next(); !f.is_empty(); f = da->get_next()) {
if (!da->current_is_dir() || f.begins_with(".")) {
continue;
}
String cand = dir.path_join(f).path_join(kTail);
if (FileAccess::file_exists(cand)) {
hit = cand;
break;
}
}
da->list_dir_end();
}
dir = dir.get_base_dir();
}
if (hit.is_empty()) {
UtilityFunctions::push_warning("[Metin2Model] specular_power set but spheremap.jpg not found");
return Ref<godot::Texture2D>();
}
Ref<godot::Image> img;
img.instantiate();
if (img->load(hit) != OK) {
UtilityFunctions::push_warning("[Metin2Model] failed to decode " + hit);
return Ref<godot::Texture2D>();
}
sphere_map = ImageTexture::create_from_image(img);
UtilityFunctions::print("[Metin2Model] sphere map: " + hit);
return sphere_map;
}
// "d:/ymir work/pc2/warrior/x.gr2" (referenced from a .msm/.msa at `base`) ->
// absolute path. Walks up from base's dir trying each level as an asset root
// against "<level>/PC/ymir work/<tail>" and "<level>/ymir work/<tail>".
String Metin2Model::resolve_rel_gr2(const String &base, const String &spec) {
String p = spec.replace("\\", "/");
if (FileAccess::file_exists(p)) { // already absolute
return p;
}
String base_dir = base.get_base_dir();
String sib = base_dir.path_join(p.get_file());
if (FileAccess::file_exists(sib)) {
return sib;
}
int kp = p.to_lower().find("ymir work/");
String yw_tail = (kp >= 0) ? p.substr(kp + 10) : p; // "pc2/warrior/x.gr2"
String dir = base_dir;
String assets_root;
for (int up = 0; up < 8 && !dir.is_empty() && dir != "/"; ++up) {
for (const String &pre : { String("PC/ymir work/"), String("ymir work/"),
String("PC/Ymir Work/"), String("") }) {
String cand = dir.path_join(pre + yw_tail);
if (FileAccess::file_exists(cand)) {
return cand;
}
}
if (assets_root.is_empty()) {
Ref<DirAccess> da = DirAccess::open(dir);
if (da.is_valid() && da->dir_exists(dir.path_join("PC"))) {
assets_root = dir;
}
}
dir = dir.get_base_dir();
}
// eterpack layout: the model may live in another loose-file pack dir
// (assets/<pack>/ymir work/<tail>). Scan the asset root's subdirs.
if (!assets_root.is_empty()) {
Ref<DirAccess> da = DirAccess::open(assets_root);
if (da.is_valid()) {
da->list_dir_begin();
for (String f = da->get_next(); !f.is_empty(); f = da->get_next()) {
if (!da->current_is_dir() || f.begins_with(".")) {
continue;
}
String cand = assets_root.path_join(f).path_join("ymir work/") + yw_tail;
if (FileAccess::file_exists(cand)) {
da->list_dir_end();
return cand;
}
}
da->list_dir_end();
}
}
return sib; // best guess
}
Ref<godot::ImageTexture> Metin2Model::_load_dds(const String &path) {
Ref<ImageTexture> t;
if (tex_cache.has(path)) {
return tex_cache[path];
}
if (FileAccess::file_exists(path)) {
mtgodot::Image dds = mtgodot::dds_from_file(path);
if (dds.ok()) {
// Character skins are sRGB colour -> eligible for mobile ASTC
// (mtgodot::make_color_texture; no-op on desktop). §F4.
t = mtgodot::make_color_texture(dds.w, dds.h, dds.rgba.data(),
dds.rgba.size(), /*mipmaps=*/true);
}
}
tex_cache[path] = t;
return t;
}
// Best-effort texture pick for a surface. libgr2's POD API gives no material or
// texture names yet (see GODOT-POC-PLAN.md §M2.5 gap list), so this is filename
// convention + a case-insensitive directory scan.
Ref<godot::ImageTexture> Metin2Model::_resolve_texture(const String &dir, const String &stem,
const String &surface_name, const PackedStringArray &dds_files) {
String sn = surface_name.to_lower();
auto scan = [&](const String &needle) -> Ref<ImageTexture> {
for (int i = 0; i < dds_files.size(); ++i) {
if (dds_files[i].to_lower().find(needle) != -1) {
Ref<ImageTexture> t = _load_dds(dir.path_join(dds_files[i]));
if (t.is_valid()) {
return t;
}
}
}
return Ref<ImageTexture>();
};
// 1. surface-name keyword slots
if (sn.find("face") != -1) {
Ref<ImageTexture> t = _load_dds(dir.path_join(stem + String("_face.dds")));
if (t.is_null()) {
t = scan("face");
}
if (t.is_valid()) {
return t;
}
}
if (sn.find("hair") != -1) {
Ref<ImageTexture> t = _load_dds(dir.path_join(stem + String("_hair.dds")));
if (t.is_null()) {
t = scan("hair");
}
if (t.is_valid()) {
return t;
}
}
// 2. surface name used verbatim as a texture basename
if (!sn.is_empty()) {
Ref<ImageTexture> t = _load_dds(dir.path_join(surface_name + String(".dds")));
if (t.is_valid()) {
return t;
}
}
// 3. exact stem
Ref<ImageTexture> t = _load_dds(dir.path_join(stem + String(".dds")));
if (t.is_valid()) {
return t;
}
// 4. any dds whose name contains the stem, then any non-face/hair dds
t = scan(stem);
if (t.is_valid()) {
return t;
}
for (int i = 0; i < dds_files.size(); ++i) {
String f = dds_files[i].to_lower();
if (f.find("face") == -1 && f.find("hair") == -1 && f.find("_lod") == -1) {
Ref<ImageTexture> any = _load_dds(dir.path_join(dds_files[i]));
if (any.is_valid()) {
return any;
}
}
}
return Ref<ImageTexture>();
}
static mtgodot::BlendMode guess_blend(const String &surface_name) {
String sn = surface_name.to_lower();
if (sn.find("effect") != -1 || sn.find("glow") != -1 || sn.find("aura") != -1) {
return mtgodot::BlendMode::Add;
}
if (sn.find("hair") != -1) {
return mtgodot::BlendMode::AlphaTest; // Metin2 hair = alpha-test cutout
}
if (sn.find("cloak") != -1 || sn.find("cape") != -1 ||
sn.find("skirt") != -1 || sn.find("ribbon") != -1) {
return mtgodot::BlendMode::Alpha;
}
return mtgodot::BlendMode::Opaque;
}
// gr2 material for a mesh-local texture basename: match dump_materials() by the
// diffuse_texture / material name (both reduced to lowercase basename).
static const gr2::MaterialInfo *match_material(const std::vector<gr2::MaterialInfo> &mats,
const String &want_base) {
if (want_base.is_empty()) {
return nullptr;
}
std::string w(want_base.utf8().get_data());
for (const auto &m : mats) {
String dt = String(m.diffuse_texture.c_str())
.replace("\\", "/")
.get_file()
.get_basename()
.to_lower();
if (dt == want_base || String(m.name.c_str()).to_lower() == want_base) {
return &m;
}
}
return nullptr;
}
// Blend mode: prefer the GR2 material flag (EterGrnLib Material.cpp:233 = Name
// "Blend*" + MapCount>1); fall back to the surface-name heuristic for the cases
// GR2 doesn't encode (hair cutout, effect additive, cloth alpha).
static mtgodot::BlendMode decide_blend(const gr2::MaterialInfo *m, const String &sname) {
String sn = sname.to_lower();
if (sn.find("effect") != -1 || sn.find("glow") != -1 || sn.find("aura") != -1) {
return mtgodot::BlendMode::Add;
}
if (sn.find("hair") != -1) {
return mtgodot::BlendMode::AlphaTest;
}
if (m && m->alpha_blend) {
return mtgodot::BlendMode::Alpha;
}
return guess_blend(sname);
}
static bool decide_two_sided(const gr2::MaterialInfo *m, const String &sname) {
if (m && m->two_sided) {
return true;
}
String sn = sname.to_lower();
return sn.find("hair") != -1 || sn.find("cloak") != -1 || sn.find("cape") != -1 ||
sn.find("skirt") != -1 || sn.find("ribbon") != -1 || sn.find("leaf") != -1;
}
void Metin2Model::_apply_materials() {
if (!mi || !file || !*file) {
return;
}
const String dir = _guess_texture_dir();
const String stem = gr2_path.get_file().get_basename();
PackedStringArray dds_files;
{
Ref<DirAccess> da = DirAccess::open(dir);
if (da.is_valid()) {
da->list_dir_begin();
for (String f = da->get_next(); !f.is_empty(); f = da->get_next()) {
if (!da->current_is_dir() && f.to_lower().ends_with(".dds")) {
dds_files.push_back(f);
}
}
da->list_dir_end();
}
}
// Surface s <-> parts[s]. tri_group.material_index is a MESH-LOCAL index into
// that mesh's MaterialBindings, so the texture name comes from
// Mesh::material_textures[mat_index] (NOT the global dump_materials() list —
// that mixed body/face up). Use the active LOD's fileinfo (§2.10).
const gr2::FileInfo *afi_ = active_fi();
if (!afi_) {
return;
}
const gr2::FileInfo &fi = *afi_;
auto find_dds = [&](const String &want) -> Ref<ImageTexture> {
if (want.is_empty()) {
return Ref<ImageTexture>();
}
for (int i = 0; i < dds_files.size(); ++i) {
if (dds_files[i].get_basename().to_lower() == want) {
return _load_dds(dir.path_join(dds_files[i]));
}
}
return Ref<ImageTexture>();
};
Ref<ArrayMesh> mesh = mi->get_mesh();
int textured = 0;
const int nsurf = mesh->get_surface_count();
surf_mats.assign(nsurf, Ref<Material>());
// §2.7 sphere-map specular: applied uniformly to all skin parts here (no
// per-part item_proto to distinguish body armor from face/hair). 0 = skip.
Ref<godot::Texture2D> smap =
(specular_power > 0.0 && material_mode != String("standard")) ? _load_sphere_map()
: Ref<godot::Texture2D>();
for (int s = 0; s < nsurf; ++s) {
const String sname = mesh->surface_get_name(s);
Ref<ImageTexture> tex;
// §2.2: appended GPU-hair surfaces — skinned shader + hair albedo + cutout.
if (gpu_skin_active && s >= gpu_base_surf) {
mtgodot::MaterialDesc md;
md.albedo = hair_tex;
md.blend = mtgodot::BlendMode::AlphaTest;
md.alpha_scissor = 0.5f;
md.skinned = true;
md.bones_tex = bones_tex;
Ref<Material> hm = mtgodot::make_material(md);
surf_mats[s] = hm;
mi->set_surface_override_material(s, hm);
continue;
}
String mat_want; // mesh-local material -> dds basename
if (s < (int)parts.size()) {
const mtgodot::RenderPart &rp = parts[s];
const gr2::Mesh &gm = fi.meshes[rp.mesh];
if (rp.mat_index >= 0 && rp.mat_index < (int)gm.material_textures.size()) {
mat_want = String(gm.material_textures[rp.mat_index].c_str())
.replace("\\", "/")
.get_file()
.get_basename()
.to_lower();
}
}
if (s < surface_tex_override.size() && !String(surface_tex_override[s]).is_empty()) {
tex = _load_dds(surface_tex_override[s]);
}
// gr2 material binding -> texture (default; set use_gr2_materials=false to skip)
if (tex.is_null() && use_gr2_materials) {
tex = find_dds(mat_want);
}
// filename-convention heuristic (fallback when the binding resolved nothing)
if (tex.is_null()) {
tex = _resolve_texture(dir, stem, sname, dds_files);
}
if (tex.is_valid()) {
++textured;
}
Ref<Material> mat;
if (material_mode == String("standard")) {
Ref<StandardMaterial3D> sm;
sm.instantiate();
sm->set_cull_mode(BaseMaterial3D::CULL_DISABLED);
if (tex.is_valid()) {
sm->set_texture(BaseMaterial3D::TEXTURE_ALBEDO, tex);
} else {
sm->set_albedo(Color(0.8, 0.8, 0.82));
}
mat = sm;
} else {
const gr2::MaterialInfo *gm_mat = match_material(materials, mat_want);
mtgodot::MaterialDesc md;
md.albedo = tex;
md.blend = decide_blend(gm_mat, sname);
md.two_sided = decide_two_sided(gm_mat, sname);
md.skinned = gpu_skin_active;
md.bones_tex = bones_tex;
if (smap.is_valid()) {
md.spec_map = smap;
md.spec_power = (float)specular_power;
}
mat = mtgodot::make_material(md);
}
surf_mats[s] = mat;
mi->set_surface_override_material(s, mat);
}
UtilityFunctions::print(vformat("[Metin2Model] materials: %d/%d surfaces textured (dir=%s, %d dds)",
textured, mesh->get_surface_count(), dir, (int)dds_files.size()));
}
void Metin2Model::_build_gpu_mesh() {
if (!mi || !file || !*file) {
return;
}
const gr2::FileInfo *bfi = active_fi();
if (!bfi) {
return;
}
AABB b;
Ref<ArrayMesh> combined = build_mesh(*bfi, parts, flip_winding, b);
gpu_base_surf = combined->get_surface_count();
if (hair_file && *hair_file && !hair_parts.empty() && !hair_bone_remap.empty()) {
const gr2::FileInfo &hfi = (**hair_file).file_info();
AABB hb;
Ref<ArrayMesh> hm = build_mesh(hfi, hair_parts, flip_winding, hb);
for (int s = 0; s < hm->get_surface_count(); ++s) {
Array arr = hm->surface_get_arrays(s);
PackedInt32Array bones = arr[Mesh::ARRAY_BONES];
for (int k = 0; k < bones.size(); ++k) {
int hi = bones[k];
int bi = (hi >= 0 && hi < (int)hair_bone_remap.size()) ? hair_bone_remap[hi] : -1;
bones.set(k, bi >= 0 ? bi : 0);
}
arr[Mesh::ARRAY_BONES] = bones;
combined->add_surface_from_arrays(Mesh::PRIMITIVE_TRIANGLES, arr);
combined->surface_set_name(combined->get_surface_count() - 1,
String("hair_") + String::num_int64(s));
}
}
mi->set_mesh(combined);
}
const gr2::Skeleton *Metin2Model::gr2_skeleton() const {
if (!file || !*file) {
return nullptr;
}
const gr2::FileInfo &fi = (*file)->file_info();
return fi.skeletons.empty() ? nullptr : &fi.skeletons[0];
}
const gr2::FileInfo *Metin2Model::gr2_fileinfo() const {
return (file && *file) ? &(*file)->file_info() : nullptr;
}
namespace {
// row-major 4x4 (translation elem 12..14), row-vector: out = [v 1] * M
inline void xform_pt(const gr2::Mat4 &M, const float *v, float *o) {
o[0] = v[0] * M[0] + v[1] * M[4] + v[2] * M[8] + M[12];
o[1] = v[0] * M[1] + v[1] * M[5] + v[2] * M[9] + M[13];
o[2] = v[0] * M[2] + v[1] * M[6] + v[2] * M[10] + M[14];
}
inline void xform_dir(const gr2::Mat4 &M, const float *v, float *o) {
o[0] = v[0] * M[0] + v[1] * M[4] + v[2] * M[8];
o[1] = v[0] * M[1] + v[1] * M[5] + v[2] * M[9];
o[2] = v[0] * M[2] + v[1] * M[6] + v[2] * M[10];
}
} // namespace
void Metin2Model::enable_cpu_skin(bool on) {
if (!mi || !file || !*file) {
return;
}
if (on && cpu_mesh.is_null()) {
AABB b;
const gr2::FileInfo *afi_ = active_fi();
if (!afi_) {
return;
}
if (parts.empty()) {
parts = mtgodot::build_parts(*afi_);
}
cpu_mesh = build_mesh(*afi_, parts, flip_winding, b);
mi->set_skeleton_path(NodePath());
mi->set_skin(Ref<Skin>());
mi->set_mesh(cpu_mesh);
if (skel) {
skel->set_show_rest_only(true);
}
UtilityFunctions::print("[Metin2Model] CPU-skin mode ON");
}
}
void Metin2Model::cpu_skin(const std::vector<gr2::Mat4> &skin) {
if (cpu_mesh.is_null() || !file || !*file) {
return;
}
const gr2::FileInfo *afi_ = active_fi();
if (!afi_) {
return;
}
const gr2::FileInfo &fi = *afi_; // active LOD mesh data (§2.10)
current_skin = skin;
static const gr2::Mat4 I{ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 };
// Skin each referenced mesh's vertices once; parts of the same mesh share them.
std::vector<PackedVector3Array> mesh_pos(fi.meshes.size());
std::vector<PackedVector3Array> mesh_nrm(fi.meshes.size());
std::vector<PackedVector2Array> mesh_uv(fi.meshes.size());
std::vector<bool> skinned(fi.meshes.size(), false);
AABB animated_bounds;
bool have_animated_bounds = false;
auto skin_mesh = [&](int midx) {
if (skinned[midx]) {
return;
}
skinned[midx] = true;
const gr2::Mesh &m = fi.meshes[midx];
const int nv = (int)m.vertices.size();
mesh_pos[midx].resize(nv);
mesh_nrm[midx].resize(nv);
mesh_uv[midx].resize(nv);
Vector3 *pw = mesh_pos[midx].ptrw();
Vector3 *nw = mesh_nrm[midx].ptrw();
Vector2 *uw = mesh_uv[midx].ptrw();
auto bone_of = [&](int slot) -> const gr2::Mat4 & {
if (slot < 0 || slot >= (int)m.bone_bindings.size()) {
return I;
}
int b = m.bone_bindings[slot];
return (b < 0 || b >= (int)skin.size()) ? I : skin[b];
};
for (int i = 0; i < nv; ++i) {
const gr2::Vertex &v = m.vertices[i];
float wq[4];
float sw = 0;
for (int k = 0; k < 4; ++k) {
sw += v.bone_weight[k];
}
for (int k = 0; k < 4; ++k) {
wq[k] = (sw > 0) ? v.bone_weight[k] / sw : (k == 0 ? 1.f : 0.f);
}
gr2::Mat4 M{};
for (int k = 0; k < 4; ++k) {
const gr2::Mat4 &bm = bone_of(v.bone_index[k]);
for (int e = 0; e < 16; ++e) {
M[e] += wq[k] * bm[e];
}
}
gr2::Mat4 M3 = M;
M3[12] = M3[13] = M3[14] = 0;
float p[3], nn[3];
xform_pt(M, v.pos, p);
xform_dir(M3, v.normal, nn);
float ln = std::sqrt(nn[0] * nn[0] + nn[1] * nn[1] + nn[2] * nn[2]);
if (ln > 1e-8f) {
nn[0] /= ln;
nn[1] /= ln;
nn[2] /= ln;
}
pw[i] = Vector3(p[0], p[1], p[2]);
if (std::isfinite(p[0]) && std::isfinite(p[1]) && std::isfinite(p[2])) {
if (have_animated_bounds) {
animated_bounds = animated_bounds.expand(pw[i]);
} else {
animated_bounds = AABB(pw[i], Vector3());
have_animated_bounds = true;
}
}
nw[i] = Vector3(nn[0], nn[1], nn[2]);
uw[i] = Vector2(v.uv0[0], v.uv0[1]);
}
};
cpu_mesh->clear_surfaces();
for (const mtgodot::RenderPart &rp : parts) {
skin_mesh(rp.mesh);
const gr2::Mesh &m = fi.meshes[rp.mesh];
const uint32_t ib = rp.idx_first;
const uint32_t ic = (rp.idx_count && ib + rp.idx_count <= m.indices.size())
? rp.idx_count
: (uint32_t)m.indices.size() - ib;
PackedInt32Array idx;
idx.resize((int)ic);
int32_t *iw = idx.ptrw();
for (uint32_t t = 0; t < ic; ++t) {
iw[t] = (int)m.indices[ib + t];
}
Array arrays;
arrays.resize(Mesh::ARRAY_MAX);
arrays[Mesh::ARRAY_VERTEX] = mesh_pos[rp.mesh];
arrays[Mesh::ARRAY_NORMAL] = mesh_nrm[rp.mesh];
arrays[Mesh::ARRAY_TEX_UV] = mesh_uv[rp.mesh];
arrays[Mesh::ARRAY_INDEX] = idx;
cpu_mesh->add_surface_from_arrays(Mesh::PRIMITIVE_TRIANGLES, arrays);
}
const int body_surfaces = cpu_mesh->get_surface_count();
// --- hair pass: skin the hair meshes with the base skeleton (bones remapped by name) ---
int hair_surfaces = 0;
if (hair_file && *hair_file && !hair_parts.empty()) {
const gr2::FileInfo &hfi = (**hair_file).file_info();
std::vector<PackedVector3Array> hp(hfi.meshes.size()), hn(hfi.meshes.size());
std::vector<PackedVector2Array> hu(hfi.meshes.size());
std::vector<bool> hs(hfi.meshes.size(), false);
auto skin_hair = [&](int midx) {
if (hs[midx])
return;
hs[midx] = true;
const gr2::Mesh &m = hfi.meshes[midx];
const int nv = (int)m.vertices.size();
hp[midx].resize(nv);
hn[midx].resize(nv);
hu[midx].resize(nv);
Vector3 *pw = hp[midx].ptrw();
Vector3 *nw = hn[midx].ptrw();
Vector2 *uw = hu[midx].ptrw();
auto bone_of = [&](int slot) -> const gr2::Mat4 & {
if (slot < 0 || slot >= (int)m.bone_bindings.size())
return I;
int hb = m.bone_bindings[slot]; // hair-skel index
int bb = (hb >= 0 && hb < (int)hair_bone_remap.size()) ? hair_bone_remap[hb] : -1;
return (bb < 0 || bb >= (int)skin.size()) ? I : skin[bb];
};
for (int i = 0; i < nv; ++i) {
const gr2::Vertex &v = m.vertices[i];
float sw = 0, wq[4];
for (int k = 0; k < 4; ++k)
sw += v.bone_weight[k];
for (int k = 0; k < 4; ++k)
wq[k] = (sw > 0) ? v.bone_weight[k] / sw : (k == 0 ? 1.f : 0.f);
gr2::Mat4 M{};
for (int k = 0; k < 4; ++k) {
const gr2::Mat4 &bm = bone_of(v.bone_index[k]);
for (int e = 0; e < 16; ++e)
M[e] += wq[k] * bm[e];
}
gr2::Mat4 M3 = M;
M3[12] = M3[13] = M3[14] = 0;
float p[3], nn[3];
xform_pt(M, v.pos, p);
xform_dir(M3, v.normal, nn);
float ln = std::sqrt(nn[0] * nn[0] + nn[1] * nn[1] + nn[2] * nn[2]);
if (ln > 1e-8f) {
nn[0] /= ln;
nn[1] /= ln;
nn[2] /= ln;
}
pw[i] = Vector3(p[0], p[1], p[2]);
nw[i] = Vector3(nn[0], nn[1], nn[2]);
uw[i] = Vector2(v.uv0[0], v.uv0[1]);
}
};
for (const mtgodot::RenderPart &rp : hair_parts) {
if (rp.mesh < 0 || rp.mesh >= (int)hfi.meshes.size())
continue;
skin_hair(rp.mesh);
const gr2::Mesh &m = hfi.meshes[rp.mesh];
const uint32_t ib = rp.idx_first;
const uint32_t ic = (rp.idx_count && ib + rp.idx_count <= m.indices.size())
? rp.idx_count
: (uint32_t)m.indices.size() - ib;
PackedInt32Array idx;
idx.resize((int)ic);
int32_t *iw = idx.ptrw();
for (uint32_t t = 0; t < ic; ++t)
iw[t] = (int)m.indices[ib + t];
Array a;
a.resize(Mesh::ARRAY_MAX);
a[Mesh::ARRAY_VERTEX] = hp[rp.mesh];
a[Mesh::ARRAY_NORMAL] = hn[rp.mesh];
a[Mesh::ARRAY_TEX_UV] = hu[rp.mesh];
a[Mesh::ARRAY_INDEX] = idx;
cpu_mesh->add_surface_from_arrays(Mesh::PRIMITIVE_TRIANGLES, a);
++hair_surfaces;
}
}
// clear_surfaces() dropped the override materials — just re-assign the cached
// ones. surf_mats is sized to the full surface count (body + folded hair) by
// _apply_materials(); here we only rebind the body slots, so `>=` is the
// right test. Using `==` made this fall through to _apply_materials() EVERY
// frame whenever hair was present (surf_mats bigger than body_surfaces) —
// reloading every DDS per frame, stalling the frame loop (missed PONGs ->
// server disconnect) and thrashing the mesh.
if (body_surfaces > 0 && (int)surf_mats.size() >= body_surfaces) {
for (int s = 0; s < body_surfaces; ++s)
mi->set_surface_override_material(s, surf_mats[s]);
} else {
_apply_materials();
}
for (int s = 0; s < hair_surfaces; ++s)
mi->set_surface_override_material(body_surfaces + s, hair_mat);
if (mi && have_animated_bounds) {
mi->set_custom_aabb(animated_bounds.grow(0.01));
}
}
void Metin2Model::enable_gpu_skin(bool on) {
if (!on || gpu_skin_active || !mi || !file || !*file) {
return;
}
const gr2::FileInfo &fi = (*file)->file_info();
const int nbones = fi.skeletons.empty() ? 0 : (int)fi.skeletons[0].bones.size();
if (nbones == 0) {
return;
}
bones_img = godot::Image::create_empty(3, nbones, false, godot::Image::FORMAT_RGBAF);
bones_tex = ImageTexture::create_from_image(bones_img);
// §2.2: fold the attached hair into the GPU mesh (bind pose, bone indices
// remapped to the base skeleton). No shader change — SRC_SKIN + the shared
// bones_tex deform it. No hair -> mesh unchanged.
gpu_base_surf = mi->get_mesh().is_valid() ? mi->get_mesh()->get_surface_count() : 0;
if (hair_file && *hair_file && !hair_parts.empty() && !hair_bone_remap.empty()) {
_build_gpu_mesh();
}
mi->set_skeleton_path(NodePath()); // no Skeleton3D skinning — the shader does it
mi->set_skin(Ref<Skin>());
if (skel) {
skel->set_show_rest_only(true);
}
// The mesh AABB is bind-pose; the shader moves verts, so widen the cull box
// (gr2-skin-space units, ~100/m) or arms-up poses get frustum/shadow-culled.
mi->set_custom_aabb(AABB(Vector3(-400, -400, -400), Vector3(800, 800, 800)));
gpu_skin_active = true;
_apply_materials(); // rebuild surface materials with the skinned shader + bones_tex
UtilityFunctions::print(vformat("[Metin2Model] GPU-skin mode ON (%d bones)", nbones));
}
void Metin2Model::gpu_skin(const std::vector<gr2::Mat4> &skin) {
if (!gpu_skin_active || bones_img.is_null()) {
return;
}
current_skin = skin;
const int nbones = bones_img->get_height();
PackedByteArray buf;
buf.resize(nbones * 3 * 4 * 4); // rows * 3 texels * RGBA * float
float *f = reinterpret_cast<float *>(buf.ptrw());
static const gr2::Mat4 I{ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 };
for (int b = 0; b < nbones; ++b) {
const gr2::Mat4 &m = (b < (int)skin.size()) ? skin[b] : I;
float *r = f + b * 12;
// texel j = column j of the row-vector skin matrix ( [pos 1] * M )
r[0] = m[0]; r[1] = m[4]; r[2] = m[8]; r[3] = m[12];
r[4] = m[1]; r[5] = m[5]; r[6] = m[9]; r[7] = m[13];
r[8] = m[2]; r[9] = m[6]; r[10] = m[10]; r[11] = m[14];
}
bones_img->set_data(3, nbones, false, godot::Image::FORMAT_RGBAF, buf);
bones_tex->update(bones_img);
}
String Metin2Model::get_info() const {
return last_info;
}
String Metin2Model::probe_gr2(const String &path) const {
gr2::LoadError err;
auto f = mtgodot::gr2_from_file(path, &err);
if (!f) {
return vformat("gr2 load FAILED [%s]: %s", String(err.stage.c_str()), String(err.message.c_str()));
}
return vformat("gr2 OK: format_version=%d sections=%d",
(int)f->format_version(), (int)f->sections().size());
}
} // namespace mtgodot