feat(client): 完成40250客户端核心功能1:1对齐与桥梁高度采样修复

- 桥梁与静态物体高度采样修复:
  - 严格对齐 40250 CMapOutdoor::GetHeight 与 CAttributeInstance::GetHeight
  - 解析 .mdatr 中的 AttributeHeight 网格,使用 is_in_triangle_2d 准确计算桥面多边形平面方程
  - sample_height 查询邻近区块并返回 fMAX(fObjectHeight, fTerrainHeight),彻底解决走上桥面穿透掉入水底/河床的问题
  - 新增 test_bridge_height_parity.gd 自动化对拍测试
- 40250 怪物击杀经验动效:
  - 1:1 实现 FLY_EXP(0) / FLY_HP / FLY_SP 粒子轨迹与爆炸吸附
- 40250 客户端全系统功能对齐(Batches 1-31):
  - 包含公会、交易、骑乘、变身、钓鱼、采矿、商城、信件、结婚、地牢等 134 套对拍系统与自动化回归测试
- 文档沉淀:
  - 新增 docs/CLIENT-PARITY-AUDIT-AND-FIX-GUIDE.md 客户端对拍缺陷发现与修复工程指南
This commit is contained in:
shen
2026-09-19 08:51:25 -07:00
parent 1db9e9a129
commit 66d217b313
650 changed files with 53046 additions and 1586 deletions
+82 -22
View File
@@ -40,6 +40,22 @@ static const gr2::MaterialInfo *match_material(const std::vector<gr2::MaterialIn
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);
static HashMap<String, Ref<ImageTexture>> s_model_tex_cache;
static HashMap<String, PackedStringArray> s_model_dir_dds_cache;
static bool model_diagnostics() {
static int diag = -1;
if (diag == -1) {
diag = (OS::get_singleton()->get_environment("MTGODOT_MODEL_DIAGNOSTICS") == "1") ? 1 : 0;
}
return diag == 1;
}
void Metin2Model::clear_texture_cache() {
s_model_tex_cache.clear();
s_model_dir_dds_cache.clear();
}
Metin2Model::Metin2Model() {
file = std::make_shared<std::optional<gr2::File>>();
lod_dist.push_back(18.0f);
@@ -66,6 +82,8 @@ void Metin2Model::_bind_methods() {
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("set_skin_texture", "source", "target"), &Metin2Model::set_skin_texture);
ClassDB::bind_method(D_METHOD("clear_skin_textures"), &Metin2Model::clear_skin_textures);
ClassDB::bind_method(D_METHOD("reload"), &Metin2Model::reload);
ClassDB::bind_method(D_METHOD("get_info"), &Metin2Model::get_info);
ClassDB::bind_method(D_METHOD("get_visual_aabb"), &Metin2Model::get_visual_aabb);
@@ -85,6 +103,7 @@ void Metin2Model::_bind_methods() {
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);
ClassDB::bind_static_method("Metin2Model", D_METHOD("clear_texture_cache"), &Metin2Model::clear_texture_cache);
// A renderer consumer may have framed the model before an animation switches
// it from the CPU mesh to GPU skinning. That switch updates the effective
// AABB, so expose a small notification instead of making each scene guess a
@@ -159,6 +178,28 @@ void Metin2Model::set_surface_texture(int surface, const String &path) {
}
}
void Metin2Model::set_skin_texture(const String &source, const String &target) {
const String key = source.replace("\\", "/").get_file().get_basename().to_lower();
if (key.is_empty()) {
return;
}
if (target.is_empty()) {
skin_tex_override.erase(key);
} else {
skin_tex_override[key] = target;
}
if (is_inside_tree() && mi) {
_apply_materials();
}
}
void Metin2Model::clear_skin_textures() {
skin_tex_override.clear();
if (is_inside_tree() && mi) {
_apply_materials();
}
}
void Metin2Model::set_gr2_path(const String &p) {
gr2_path = p;
if (is_inside_tree()) {
@@ -236,7 +277,9 @@ void Metin2Model::_load_lods(const String &loaded_spec) {
lod_files.push_back(std::move(slot));
}
if (!lod_files.empty()) {
UtilityFunctions::print(vformat("[Metin2Model] LOD: %d level(s) loaded", (int)lod_files.size()));
if (model_diagnostics()) {
UtilityFunctions::print(vformat("[Metin2Model] LOD: %d level(s) loaded", (int)lod_files.size()));
}
}
}
@@ -518,7 +561,7 @@ void Metin2Model::reload() {
for (const auto &m : fi.meshes) {
vtot += (int)m.vertices.size();
}
{
if (model_diagnostics()) {
int with_tex = 0;
for (const auto &mm : materials) {
if (!mm.diffuse_texture.empty()) {
@@ -535,7 +578,9 @@ void Metin2Model::reload() {
mesh->get_surface_count(),
vtot,
String(bounds));
UtilityFunctions::print("[Metin2Model] ", last_info);
if (model_diagnostics()) {
UtilityFunctions::print("[Metin2Model] ", last_info);
}
_load_lods(gr2_spec);
@@ -738,19 +783,18 @@ void Metin2Model::_load_attach(const String &gr2_rel, const String &bone_name,
*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.
// Grip pre-transform, mirroring Granny's rigid-mesh composite in the client:
// weaponWorld[0] = weaponLocal[0] · handWorld
// weaponComposite[0] = weaponInverseWorld[0] · weaponWorld[0]
// The explicit parent matrix passed to GrannyBuildWorldPose replaces the
// weapon model's InitialPlacement; multiplying it here would cancel 03150's
// inverse-bind correction. See ModelInstanceUpdate.cpp:148.
// Do not clamp a large composite translation: files such as 03150 also store
// their raw vertices metres away from the skeleton origin, and this authored
// inverse-bind transform is precisely what moves the grip back onto the hand.
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;
slot_pre = mtgodot::mul4x3(wb0.inverse_world, wb0.local_transform);
}
std::vector<mtgodot::RenderPart> wparts = mtgodot::build_parts(wfi);
@@ -987,8 +1031,8 @@ String Metin2Model::resolve_rel_gr2(const String &base, const String &spec) {
Ref<godot::ImageTexture> Metin2Model::_load_dds(const String &path) {
Ref<ImageTexture> t;
if (tex_cache.has(path)) {
return tex_cache[path];
if (s_model_tex_cache.has(path)) {
return s_model_tex_cache[path];
}
if (FileAccess::file_exists(path)) {
mtgodot::Image dds = mtgodot::dds_from_file(path);
@@ -999,7 +1043,7 @@ Ref<godot::ImageTexture> Metin2Model::_load_dds(const String &path) {
dds.rgba.size(), /*mipmaps=*/true);
}
}
tex_cache[path] = t;
s_model_tex_cache[path] = t;
return t;
}
@@ -1140,7 +1184,9 @@ void Metin2Model::_apply_materials() {
const String stem = gr2_path.get_file().get_basename();
PackedStringArray dds_files;
{
if (s_model_dir_dds_cache.has(dir)) {
dds_files = s_model_dir_dds_cache[dir];
} else {
Ref<DirAccess> da = DirAccess::open(dir);
if (da.is_valid()) {
da->list_dir_begin();
@@ -1151,6 +1197,7 @@ void Metin2Model::_apply_materials() {
}
da->list_dir_end();
}
s_model_dir_dds_cache[dir] = dds_files;
}
// Surface s <-> parts[s]. tri_group.material_index is a MESH-LOCAL index into
@@ -1230,6 +1277,13 @@ void Metin2Model::_apply_materials() {
if (s < surface_tex_override.size() && !String(surface_tex_override[s]).is_empty()) {
tex = _load_dds(surface_tex_override[s]);
}
// `.msm` SourceSkin -> TargetSkin remaps are material-name based in the
// 40250 client. An armour can have face/body/effect surfaces in a
// different order, so applying TargetSkin to hard-coded surface 0 gives
// the right geometry with the wrong (usually base black) appearance.
if (tex.is_null() && skin_tex_override.has(mat_want)) {
tex = _load_dds(String(skin_tex_override[mat_want]));
}
// gr2 material binding -> texture (default; set use_gr2_materials=false to skip)
if (tex.is_null() && use_gr2_materials) {
tex = find_dds(mat_want);
@@ -1270,8 +1324,10 @@ void Metin2Model::_apply_materials() {
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()));
if (model_diagnostics()) {
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() {
@@ -1353,7 +1409,9 @@ void Metin2Model::enable_cpu_skin(bool on) {
if (skel) {
skel->set_show_rest_only(true);
}
UtilityFunctions::print("[Metin2Model] CPU-skin mode ON");
if (model_diagnostics()) {
UtilityFunctions::print("[Metin2Model] CPU-skin mode ON");
}
}
}
@@ -1652,7 +1710,9 @@ void Metin2Model::enable_gpu_skin(bool on) {
// sampler has produced its skin matrices.
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));
if (model_diagnostics()) {
UtilityFunctions::print(vformat("[Metin2Model] GPU-skin mode ON (%d bones)", nbones));
}
}
void Metin2Model::gpu_skin(const std::vector<gr2::Mat4> &skin) {