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
+92 -28
View File
@@ -173,6 +173,11 @@ void Metin2AnimPlayer::_bind_methods() {
void Metin2AnimPlayer::set_anim_path(const String &p) {
if (p == anim_path) {
if (!playing) {
time = 0.0;
prev_time = 0.0;
playing = true;
}
return;
}
// Start a crossfade from the clip that is currently playing.
@@ -407,6 +412,7 @@ void Metin2AnimPlayer::reload() {
events.clear();
msa_metadata = fmt::Msa{};
last_info = "";
playing = true;
if (anim_path.is_empty()) {
return;
}
@@ -468,9 +474,11 @@ void Metin2AnimPlayer::reload() {
unmatched += String(tr.bone_name.c_str()) + " ";
}
}
UtilityFunctions::print(vformat(
"[Metin2AnimPlayer] track->bone match: %d/%d (skel bones=%d) unmatched: %s",
matched, (int)an.tracks.size(), (int)sk->bones.size(), unmatched));
if (diagnostics) {
UtilityFunctions::print(vformat(
"[Metin2AnimPlayer] track->bone match: %d/%d (skel bones=%d) unmatched: %s",
matched, (int)an.tracks.size(), (int)sk->bones.size(), unmatched));
}
}
}
last_info = vformat("anim '%s' dur=%.3f tracks=%d anims=%d",
@@ -484,22 +492,30 @@ 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".
// Blend two local bone transforms: slerp rotation, lerp translation and the
// complete scale/shear basis (PARITY §2.9). Several warrior/sura armour bones
// depend on authored shear, so it must survive the transition as well as steady
// animation sampling.
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);
const godot::Quaternion qa = ba.get_rotation_quaternion();
const godot::Quaternion qb = bb.get_rotation_quaternion();
godot::Quaternion q = qa.slerp(qb, w);
// Preserve Granny's complete scale/shear matrix. Keeping only get_scale()
// changes authored joints even at the endpoints and is especially visible in
// hands and shoulders. For B = R*S, extract S and interpolate all 3x3 terms.
const godot::Basis sa = godot::Basis(qa).inverse() * ba;
const godot::Basis sb = godot::Basis(qb).inverse() * bb;
godot::Basis ss;
for (int row = 0; row < 3; ++row) {
ss.rows[row] = sa.rows[row].lerp(sb.rows[row], 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;
const godot::Basis rb = godot::Basis(q) * ss;
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;
@@ -508,6 +524,46 @@ gr2::Mat4 blend_trs(const gr2::Mat4 &a, const gr2::Mat4 &b, float w) {
return o;
}
// Sample animation tracks into the model skeleton's LOCAL bone domain. Granny
// blends controls before BuildWorldPose; doing the crossfade after world-pose
// accumulation independently interpolates every joint endpoint and temporarily
// changes parent/child distances (visible as stretched or collapsed limbs when
// wait/walk/run switches).
void sample_local_pose(const gr2::Skeleton &sk, const gr2::Animation &an, float t,
std::vector<gr2::Mat4> &local) {
std::vector<gr2::Mat4> track_local;
an.sample_local(t, track_local);
local.resize(sk.bones.size());
for (size_t i = 0; i < sk.bones.size(); ++i) {
local[i] = sk.bones[i].local_transform;
}
for (size_t ti = 0; ti < an.tracks.size() && ti < track_local.size(); ++ti) {
for (size_t bi = 0; bi < sk.bones.size(); ++bi) {
if (sk.bones[bi].name == an.tracks[ti].bone_name) {
local[bi] = track_local[ti];
break;
}
}
}
}
void accumulate_local_pose(const gr2::Skeleton &sk, const std::vector<gr2::Mat4> &local,
const gr2::Mat4 &root_offset, std::vector<gr2::Mat4> &world,
std::vector<gr2::Mat4> &skin) {
static const gr2::Mat4 I{ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 };
const size_t n = sk.bones.size();
world.assign(n, I);
skin.assign(n, I);
for (size_t i = 0; i < n; ++i) {
const int parent = sk.bones[i].parent;
const gr2::Mat4 &parent_world =
(parent < 0 || static_cast<size_t>(parent) >= i) ? root_offset : world[parent];
world[i] = mul4x3(i < local.size() ? local[i] : sk.bones[i].local_transform,
parent_world);
skin[i] = mul4x3(sk.bones[i].inverse_world, world[i]);
}
}
} // namespace
void Metin2AnimPlayer::apply_pose(double t) {
@@ -548,32 +604,36 @@ void Metin2AnimPlayer::apply_pose(double t) {
// vertex sits within a few mm of z=0 for wait/walk on all four classes, and
// only leaves the ground during run's airborne frames.
static const gr2::Mat4 kNoOffset{ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 };
gr2::sample_pose(*sk, an, (float)tt, world_buf, skin_buf, &kNoOffset);
bool pose_ready = false;
// Crossfade: blend the frozen outgoing-clip pose into this one, then rebuild
// skin_buf = invWorld · world_blended (PARITY §2.9).
// Crossfade in LOCAL bone space, then accumulate the hierarchy and rebuild
// skin_buf = invWorld · world. Blending world transforms made bone endpoints
// take independent straight-line paths, shortening limbs around the midpoint.
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, &kNoOffset);
sample_local_pose(*sk, an, (float)tt, local_buf);
sample_local_pose(*sk, pfi.animations[0], (float)prev_anim_time, prev_local_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
const size_t n = std::min(local_buf.size(), prev_local_buf.size());
blend_local_buf.resize(local_buf.size());
for (size_t i = 0; i < n; ++i) {
blend_local_buf[i] = blend_trs(prev_local_buf[i], local_buf[i], (float)w);
}
for (size_t i = n; i < local_buf.size(); ++i) {
blend_local_buf[i] = local_buf[i];
}
accumulate_local_pose(*sk, blend_local_buf, kNoOffset, world_buf, skin_buf);
pose_ready = true;
}
}
if (!pose_ready) {
gr2::sample_pose(*sk, an, (float)tt, world_buf, skin_buf, &kNoOffset);
}
// Both skinning paths apply libgr2's per-bone deformer matrices
// (skin_buf = Σ w · invWorld · world) with the FULL affine — shear kept.
@@ -582,7 +642,11 @@ void Metin2AnimPlayer::apply_pose(double t) {
// 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") {
static const String kZero("0");
static const String kEnvName("MTGODOT_GPUSKIN");
String env = godot::OS::get_singleton()->get_environment(kEnvName);
// Default to GPU skinning (true) unless explicitly set to "0"
if (env != kZero) {
model->enable_gpu_skin(true);
model->gpu_skin(skin_buf);
} else {
+3 -1
View File
@@ -113,7 +113,9 @@ private:
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;
// Crossfades operate before hierarchy accumulation so parent/child lengths
// remain stable while switching locomotion clips.
std::vector<gr2::Mat4> local_buf, prev_local_buf, blend_local_buf;
godot::Vector3 accumulation; // .msa Accumulation (root motion)
std::vector<fmt::MotionEvent> events;
+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) {
+13 -5
View File
@@ -8,6 +8,7 @@
#include <godot_cpp/templates/hash_map.hpp>
#include <godot_cpp/variant/array.hpp>
#include <godot_cpp/variant/aabb.hpp>
#include <godot_cpp/variant/dictionary.hpp>
#include <godot_cpp/variant/packed_string_array.hpp>
#include <godot_cpp/variant/string.hpp>
@@ -81,6 +82,11 @@ public:
// Explicit per-surface texture path override (index -> absolute .dds path).
void set_surface_texture(int surface, const godot::String &path);
// Reference SetMaterialImagePointer(part, SourceSkin, TargetSkin): replace
// every GR2 material whose texture basename matches source, rather than
// assuming that an armour skin always belongs to surface 0.
void set_skin_texture(const godot::String &source, const godot::String &target);
void clear_skin_textures();
// When gr2_path is a .msm: [{index, model (abs .gr2), target_skin}, ...].
godot::Array get_hair_options() const { return hair_options; }
@@ -160,6 +166,8 @@ public:
// libgr2 sanity probe (kept from M0').
godot::String probe_gr2(const godot::String &path) const;
static void clear_texture_cache();
protected:
static void _bind_methods();
@@ -176,6 +184,7 @@ private:
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::Dictionary skin_tex_override; // lowercase source basename -> target path
godot::String resolved_gr2_dir; // dir of the actually-loaded .gr2 (for .msm)
godot::Array hair_options; // from .msm HairData
@@ -196,10 +205,10 @@ private:
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.
// Weapon root rigid-mesh composite (invWorld · local),
// folded in before the parent hand matrix exactly as Granny does for an attached
// model. This may contain a large translation which cancels an authored vertex
// offset (notably 03150), so it must not be clamped.
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
@@ -220,7 +229,6 @@ private:
godot::MeshInstance3D *mi = nullptr;
godot::String last_info;
double ground_offset = 0.0;
godot::HashMap<godot::String, godot::Ref<godot::ImageTexture>> tex_cache;
// §2.10 LOD
bool lod_enabled = true;
+126 -12
View File
@@ -153,8 +153,65 @@ bool add_attribute_collision(const fmt::AttributeCollision &collision,
return true;
}
inline float cross_product_2d(float x1, float y1, float x2, float y2) {
return x1 * y2 - y1 * x2;
}
inline bool is_in_triangle_2d(float ax, float ay, float bx, float by, float cx, float cy, float tx, float ty) {
float c1 = cross_product_2d(bx - ax, by - ay, tx - ax, ty - ay);
float c2 = cross_product_2d(cx - bx, cy - by, tx - bx, ty - by);
float c3 = cross_product_2d(ax - cx, ay - cy, tx - cx, ty - cy);
if (c1 * c2 > 0.0f && c1 * c3 > 0.0f)
return true;
if (c1 * c2 * c3 == 0.0f) {
if (tx < ax && tx < bx && tx < cx) return false;
if (tx > ax && tx > bx && tx > cx) return false;
if (ty < ay && ty < by && ty < cy) return false;
if (ty > ay && ty > by && ty > cy) return false;
return true;
}
return false;
}
void extract_height_triangles(const fmt::AttributeData &data, const Transform3D &place,
std::vector<Metin2World::HeightTriangle> &out_heights) {
for (const auto &height : data.heights) {
if (height.vertices.size() < 3)
continue;
for (size_t i = 0; i + 2 < height.vertices.size(); i += 3) {
Vector3 v0 = place.xform(attribute_position(height.vertices[i]));
Vector3 v1 = place.xform(attribute_position(height.vertices[i + 1]));
Vector3 v2 = place.xform(attribute_position(height.vertices[i + 2]));
Vector3 line1 = v1 - v0;
Vector3 line2 = v2 - v0;
Vector3 normal = line1.cross(line2);
float len = normal.length();
if (len < 1e-6f)
continue;
normal /= len;
if (std::abs(normal.y) < 0.001f)
continue; // vertical or near-vertical surface, not a walkable floor/deck
Metin2World::HeightTriangle tri;
tri.v0 = v0;
tri.v1 = v1;
tri.v2 = v2;
tri.min_x = std::min({v0.x, v1.x, v2.x});
tri.max_x = std::max({v0.x, v1.x, v2.x});
tri.min_z = std::min({v0.z, v1.z, v2.z});
tri.max_z = std::max({v0.z, v1.z, v2.z});
tri.normal = normal;
tri.d = normal.dot(v0);
out_heights.push_back(tri);
}
}
}
int add_attribute_data(const std::string &path, const Transform3D &place,
Node3D *root, MeshInstance3D *mesh) {
Node3D *root, MeshInstance3D *mesh,
std::vector<Metin2World::HeightTriangle> *out_heights = nullptr) {
fmt::AttributeData data;
std::string error;
if (!fmt::parse_attribute_data_file(path, data, &error)) {
@@ -162,6 +219,9 @@ int add_attribute_data(const std::string &path, const Transform3D &place,
" (" + error.c_str() + ")");
return 0;
}
if (out_heights) {
extract_height_triangles(data, place, *out_heights);
}
StaticBody3D *body = memnew(StaticBody3D);
body->set_name(String("AttributeBody_") + String(path.c_str()).get_file().get_basename());
body->set_collision_layer(2);
@@ -196,6 +256,15 @@ int add_attribute_data(const std::string &path, const Transform3D &place,
return shapes;
}
void load_attribute_heights(const std::string &path, const Transform3D &place,
std::vector<Metin2World::HeightTriangle> &out_heights) {
fmt::AttributeData data;
std::string error;
if (fmt::parse_attribute_data_file(path, data, &error)) {
extract_height_triangles(data, place, out_heights);
}
}
} // namespace
Metin2World::Metin2World() {}
@@ -468,7 +537,7 @@ bool Metin2World::build_chunk(int tx, int ty) {
ck.am = am;
ck.root = croot;
if (objects_enabled && registry_ok && resolver)
place_chunk_objects(tx, ty, croot, ck.objects, ck.trees);
place_chunk_objects(tx, ty, croot, ck.objects, ck.trees, ck.height_triangles);
if (registry_ok)
place_chunk_ambience(tx, ty);
objects_placed += ck.objects;
@@ -477,7 +546,8 @@ bool Metin2World::build_chunk(int tx, int ty) {
return true;
}
void Metin2World::place_chunk_objects(int tx, int ty, Node3D *root, int &n_obj, int &n_tree) {
void Metin2World::place_chunk_objects(int tx, int ty, Node3D *root, int &n_obj, int &n_tree,
std::vector<HeightTriangle> &out_heights) {
n_obj = 0;
n_tree = 0;
const std::string mdir = std::string(map_dir().utf8().get_data());
@@ -576,13 +646,16 @@ void Metin2World::place_chunk_objects(int tx, int ty, Node3D *root, int &n_obj,
// 盒尺寸/中心直接算到「米 / Y-up」,body 只带 place(旋转+平移,无 conv),
// 避免把碰撞形状放在 0.01 缩放节点下(Godot 缩放 shape 不稳)。
bool mdatr_built = false;
if (collision_enabled) {
String md = String(rp.c_str()).get_basename() + ".mdatr";
if (FileAccess::file_exists(md)) {
const int built = add_attribute_data(std::string(md.utf8().get_data()), place, root, mi);
String md = String(rp.c_str()).get_basename() + ".mdatr";
if (FileAccess::file_exists(md)) {
const std::string md_str(md.utf8().get_data());
if (collision_enabled) {
const int built = add_attribute_data(md_str, place, root, mi, &out_heights);
mdatr_built = built > 0;
if (mdatr_built)
++objects_mdatr_built;
} else {
load_attribute_heights(md_str, place, out_heights);
}
}
// A malformed/missing attribute resource still gets a conservative
@@ -880,11 +953,52 @@ double Metin2World::sample_height(double gx_m, double gz_m) const {
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;
double terrain_h = 0.0;
if (c && c->hm) {
const double lx = mx_cm - double(tx) * fmt::m2coord::CHUNK_CM;
const double ly = my_abs_cm - double(ty) * fmt::m2coord::CHUNK_CM;
terrain_h = fmt::terrain_height_at(*c->hm, lx, ly, setting.height_scale) * fmt::m2coord::CM_TO_M;
}
// 40250 CMapOutdoor::GetHeight(fx, fy):
// Check placed objects' height data (bridges, stairs, platforms from .mdatr).
// If object height > terrain height, return fMAX(fObjectHeight, fTerrainHeight).
float obj_h = -25000.0f;
bool obj_found = false;
const float fgx = float(gx_m);
const float fgz = float(gz_m);
for (int dtx = -1; dtx <= 1; ++dtx) {
int ntx = tx + dtx;
if (ntx < 0 || ntx >= setting.map_size_x)
continue;
for (int dty = -1; dty <= 1; ++dty) {
int nty = ty + dty;
if (nty < 0 || nty >= setting.map_size_y)
continue;
const Chunk *nc = chunk_at(ntx, nty);
if (!nc || nc->height_triangles.empty())
continue;
for (const auto &tri : nc->height_triangles) {
if (fgx < tri.min_x || fgx > tri.max_x ||
fgz < tri.min_z || fgz > tri.max_z)
continue;
if (is_in_triangle_2d(tri.v0.x, tri.v0.z, tri.v1.x, tri.v1.z, tri.v2.x, tri.v2.z, fgx, fgz)) {
float h = (tri.d - tri.normal.x * fgx - tri.normal.z * fgz) / tri.normal.y;
if (!obj_found || h > obj_h) {
obj_h = h;
obj_found = true;
}
}
}
}
}
if (obj_found && (c == nullptr || !c->hm || obj_h > terrain_h))
return double(obj_h);
return terrain_h;
}
Ref<godot::Image> Metin2World::load_dds(const String &path) const {
+13 -1
View File
@@ -37,6 +37,16 @@ class Metin2World : public godot::Node3D {
GDCLASS(Metin2World, godot::Node3D)
public:
struct HeightTriangle {
godot::Vector3 v0;
godot::Vector3 v1;
godot::Vector3 v2;
float min_x = 0.0f, max_x = 0.0f;
float min_z = 0.0f, max_z = 0.0f;
godot::Vector3 normal;
float d = 0.0f;
};
Metin2World();
~Metin2World() override;
@@ -154,6 +164,7 @@ private:
std::shared_ptr<fmt::AttrMap> am;
godot::Node3D *root = nullptr; // 该区块的全部场景节点(terrain + water + 对象 + 树)
int objects = 0, trees = 0;
std::vector<HeightTriangle> height_triangles;
};
struct AmbienceSource {
int tile_x = 0, tile_y = 0;
@@ -173,7 +184,8 @@ private:
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 place_chunk_objects(int tx, int ty, godot::Node3D *root, int &n_obj, int &n_tree,
std::vector<HeightTriangle> &out_heights);
void place_chunk_ambience(int tx, int ty);
void unload_chunk(int idx);
void stream_update();
+49 -6
View File
@@ -7,6 +7,25 @@
namespace mtnet::classic {
namespace {
uint8_t normalize_wire_ch_type(uint8_t type) {
switch (type) {
case WIRE_CHRTYPE_MONSTER: return CHRTYPE_MONSTER;
case WIRE_CHRTYPE_NPC: return CHRTYPE_NPC;
case WIRE_CHRTYPE_STONE: return CHRTYPE_STONE;
case WIRE_CHRTYPE_WARP: return CHRTYPE_WARP;
case WIRE_CHRTYPE_DOOR: return CHRTYPE_DOOR;
case WIRE_CHRTYPE_BUILDING: return CHRTYPE_BUILDING;
case WIRE_CHRTYPE_PC:
case WIRE_CHRTYPE_POLYMORPH_PC:
return CHRTYPE_PC;
case WIRE_CHRTYPE_HORSE: return CHRTYPE_HORSE;
case WIRE_CHRTYPE_GOTO: return CHRTYPE_GOTO;
default: return type;
}
}
} // namespace
void ClassicParser::set_npclist_path(std::string path) {
if (path == m_npclist_path) {
return;
@@ -191,9 +210,11 @@ bool ClassicParser::on_gc(uint8_t header, const uint8_t *body, uint32_t len) {
if (!fill(p, header, body, len)) {
return false;
}
m_empire = p.empire;
m_world.mut_spawn_main(p.vid, p.race,
std::string(p.name, strnlen(p.name, sizeof(p.name))), (float)p.x, (float)p.y,
(float)p.z);
m_world.mut_skill_group(p.skill_group);
return true;
}
case HDR_GC_MAIN_CHARACTER3_BGM: {
@@ -201,9 +222,11 @@ bool ClassicParser::on_gc(uint8_t header, const uint8_t *body, uint32_t len) {
if (!fill(p, header, body, len)) {
return false;
}
m_empire = p.empire;
m_world.mut_spawn_main(p.vid, p.race,
std::string(p.name, strnlen(p.name, sizeof(p.name))), (float)p.x, (float)p.y,
(float)p.z);
m_world.mut_skill_group(p.skill_group);
// §9.1: route the map's background-music track; volume unspecified.
m_world.mut_map_bgm(
std::string(p.bgm_name, strnlen(p.bgm_name, sizeof(p.bgm_name))), -1.0f);
@@ -214,9 +237,11 @@ bool ClassicParser::on_gc(uint8_t header, const uint8_t *body, uint32_t len) {
if (!fill(p, header, body, len)) {
return false;
}
m_empire = p.empire;
m_world.mut_spawn_main(p.vid, p.race,
std::string(p.name, strnlen(p.name, sizeof(p.name))), (float)p.x, (float)p.y,
(float)p.z);
m_world.mut_skill_group(p.skill_group);
// §9.1: track + server-authored volume (0..1).
m_world.mut_map_bgm(
std::string(p.bgm_name, strnlen(p.bgm_name, sizeof(p.bgm_name))),
@@ -233,7 +258,8 @@ bool ClassicParser::on_gc(uint8_t header, const uint8_t *body, uint32_t len) {
if (is_invisible_race(p.race)) {
return true;
}
if (p.type == CHRTYPE_PC || p.type == CHRTYPE_NPC) {
const uint8_t ch_type = normalize_wire_ch_type(p.type);
if (ch_type == CHRTYPE_PC || ch_type == CHRTYPE_NPC) {
// Two-packet actor: GC_CHARACTER_ADD carries no name / guild /
// parts, so stash a bare record and wait for
// GC_CHAR_ADDITIONAL_INFO to merge + flush it. The world model
@@ -242,7 +268,7 @@ bool ClassicParser::on_gc(uint8_t header, const uint8_t *body, uint32_t len) {
e = Entity{};
e.vid = p.vid;
e.race = p.race;
e.ch_type = p.type;
e.ch_type = ch_type;
e.x = (float)p.x;
e.y = (float)p.y;
e.z = (float)p.z;
@@ -257,7 +283,7 @@ bool ClassicParser::on_gc(uint8_t header, const uint8_t *body, uint32_t len) {
// Monster / stone / door / object: appended immediately, name from
// npclist.txt (race -> code, like CPythonNonPlayer::GetName); every
// PC-specific field stays 0.
m_world.mut_spawn(p.vid, p.race, p.type, npc_name_for(p.race), (float)p.x, (float)p.y,
m_world.mut_spawn(p.vid, p.race, ch_type, npc_name_for(p.race), (float)p.x, (float)p.y,
(float)p.z, p.angle, p.moving_speed, p.attack_speed);
return true;
}
@@ -266,7 +292,7 @@ bool ClassicParser::on_gc(uint8_t header, const uint8_t *body, uint32_t len) {
if (!fill(p, header, body, len)) {
return false;
}
m_world.mut_spawn(p.vid, p.race, p.type,
m_world.mut_spawn(p.vid, p.race, normalize_wire_ch_type(p.type),
std::string(p.name, strnlen(p.name, sizeof(p.name))), (float)p.x, (float)p.y,
(float)p.z, p.angle, p.moving_speed, p.attack_speed);
m_world.mut_char_info(p.vid, {}, p.parts, p.empire, (int32_t)p.guild, 0,
@@ -908,7 +934,19 @@ bool ClassicParser::on_gc(uint8_t header, const uint8_t *body, uint32_t len) {
std::memcpy(&si, cur, sizeof(si));
cur += sizeof(si);
if (si.vnum != 0) {
tab.items.push_back({si.vnum, si.price, si.count, si.display_pos});
ShopEntry entry;
entry.vnum = si.vnum;
entry.price = si.price;
entry.count = si.count;
entry.pos = (uint8_t)j;
for (int k = 0; k < ITEM_SOCKET_SLOT_MAX_NUM && k < ITEM_SOCKET_MAX_NUM; ++k) {
entry.sockets[k] = si.sockets[k];
}
for (int k = 0; k < ITEM_ATTRIBUTE_SLOT_MAX_NUM && k < ITEM_ATTRIBUTE_MAX_NUM; ++k) {
entry.attrs[k].type = si.attrs[k].type;
entry.attrs[k].value = si.attrs[k].value;
}
tab.items.push_back(std::move(entry));
}
}
};
@@ -973,8 +1011,13 @@ bool ClassicParser::on_gc(uint8_t header, const uint8_t *body, uint32_t len) {
}
ShopUpdateItem update;
std::memcpy(&update, cur, sizeof(update));
ItemAttr attrs[ITEM_ATTRIBUTE_SLOT_MAX_NUM] = {};
for (int k = 0; k < ITEM_ATTRIBUTE_SLOT_MAX_NUM && k < ITEM_ATTRIBUTE_MAX_NUM; ++k) {
attrs[k].type = update.item.attrs[k].type;
attrs[k].value = update.item.attrs[k].value;
}
m_world.mut_shop_update_item(update.pos, update.item.vnum, update.item.price,
update.item.count);
update.item.count, update.item.sockets, attrs);
return true;
}
case SHOP_SUB_UPDATE_PRICE: {
+20 -2
View File
@@ -57,8 +57,10 @@ inline constexpr int GUILD_MARK_WIDTH = 16;
inline constexpr int GUILD_MARK_HEIGHT = 12;
inline constexpr int MARK_BLOCK_TOTAL_COUNT = 80;
// CHRTYPE_* the domain of Entity::ch_type and packet_add_char.bType
// (client GameType.h / server char.h). Values line up with the Entity comment.
// CHRTYPE_* is the protocol-neutral Entity::ch_type domain used by the Godot
// presentation layer. Do not put packet_add_char.bType into Entity verbatim:
// the 40250 server's ECharType wire order is MONSTER=0, NPC=1, ... PC=6,
// whereas the presentation layer historically uses PC=0, NPC=1, MONSTER=2.
enum : uint8_t {
CHRTYPE_PC = 0,
CHRTYPE_NPC = 1,
@@ -72,6 +74,22 @@ enum : uint8_t {
CHRTYPE_GOTO = 9,
};
// Exact 40250 server/common/length.h ECharType values carried on the classic
// packet wire. Keep these names separate from CHRTYPE_* so tests cannot
// accidentally manufacture a packet with the normalized presentation value.
enum : uint8_t {
WIRE_CHRTYPE_MONSTER = 0,
WIRE_CHRTYPE_NPC = 1,
WIRE_CHRTYPE_STONE = 2,
WIRE_CHRTYPE_WARP = 3,
WIRE_CHRTYPE_DOOR = 4,
WIRE_CHRTYPE_BUILDING = 5,
WIRE_CHRTYPE_PC = 6,
WIRE_CHRTYPE_POLYMORPH_PC = 7,
WIRE_CHRTYPE_HORSE = 8,
WIRE_CHRTYPE_GOTO = 9,
};
// Races the reference client never spawns — REF/UserInterface/
// PythonNetworkStreamPhaseGameActor.cpp:66 IsInvisibleRace(). GC_CHARACTER_ADD
// for one of these is dropped before any pending/stash step.
+141 -34
View File
@@ -63,6 +63,9 @@ Entity &EntityStore::touch(uint32_t vid, bool &created) {
if (created) {
Entity e;
e.vid = vid;
if (vid == m_main_vid) {
e.is_main = true;
}
it = m_ents.emplace(vid, e).first;
}
return it->second;
@@ -113,6 +116,7 @@ void EntityStore::mut_spawn_main(uint32_t vid, uint16_t race, const std::string
e.z = z;
e.is_main = true;
e.moving = false;
e.state_queue.clear();
m_main_vid = vid;
m_changes.push_back({created ? ChangeKind::Spawn : ChangeKind::MainSet, vid});
if (!created) {
@@ -140,6 +144,10 @@ void EntityStore::mut_spawn_full(const Entity &src) {
if (e.vid == m_main_vid) {
e.is_main = true;
}
if (e.is_main) {
e.moving = false;
e.state_queue.clear();
}
m_ents[src.vid] = e;
m_changes.push_back({ChangeKind::Spawn, src.vid});
if (e.is_main) {
@@ -210,12 +218,26 @@ void EntityStore::mut_despawn(uint32_t vid) {
// past (or 0) still applies synchronously.
void EntityStore::mut_move(uint32_t vid, float angle_deg, uint8_t func, float tx, float ty,
uint32_t dur_ms, uint8_t arg, uint32_t chk_time) {
if (vid == m_main_vid) {
return;
}
auto it = m_ents.find(vid);
if (it == m_ents.end()) {
if (it == m_ents.end() || it->second.is_main) {
return;
}
Entity &e = it->second;
e.state_queue.push_back({chk_time, tx, ty, angle_deg, func, arg, dur_ms});
// 40250 CInstanceBase::PushTCPState EMA network latency smoothing:
uint32_t scheduled_time = chk_time;
if (m_server_frame_ms != 0 && chk_time != 0) {
int32_t nNetworkGap = (int32_t)m_server_frame_ms - (int32_t)chk_time;
if (e.average_network_gap == 0) {
e.average_network_gap = nNetworkGap;
} else {
e.average_network_gap = (e.average_network_gap * 70 + nNetworkGap * 30) / 100;
}
scheduled_time = chk_time + (uint32_t)std::max(0, e.average_network_gap);
}
e.state_queue.push_back({scheduled_time, tx, ty, angle_deg, func, arg, dur_ms});
drain_state_queue(e);
m_changes.push_back({ChangeKind::Move, vid});
}
@@ -351,6 +373,9 @@ void EntityStore::drain_state_queue(Entity &e) {
void EntityStore::process_states() {
for (auto &kv : m_ents) {
if (kv.second.is_main) {
continue;
}
drain_state_queue(kv.second);
}
}
@@ -412,7 +437,14 @@ void EntityStore::mut_set_point(uint8_t type, int32_t value, uint32_t vid) {
}
Entity &e = it->second;
switch (type) {
case POINT_HP: e.hp = value; break;
case POINT_HP:
e.hp = value;
if (e.hp <= 0) {
e.dead = true;
e.moving = false;
e.func = FUNC_WAIT;
}
break;
case POINT_MAX_HP: e.max_hp = value; break;
case POINT_SP: e.sp = value; break;
case POINT_MAX_SP: e.max_sp = value; break;
@@ -859,7 +891,8 @@ void EntityStore::mut_shop_open(uint32_t vid, const std::vector<ShopTab> &tabs)
m_shop_dirty = true;
}
void EntityStore::mut_shop_update_item(uint8_t pos, uint32_t vnum, uint32_t price, uint8_t count) {
void EntityStore::mut_shop_update_item(uint8_t pos, uint32_t vnum, uint32_t price, uint8_t count,
const int32_t *sockets, const ItemAttr *attrs) {
if (!m_shop_open || pos >= SHOP_HOST_ITEM_MAX_NUM) {
return;
}
@@ -871,18 +904,47 @@ void EntityStore::mut_shop_update_item(uint8_t pos, uint32_t vnum, uint32_t pric
if (vnum == 0) {
items.erase(items.begin() + (long)i);
} else {
items[i] = {vnum, price, count, pos};
items[i].vnum = vnum;
items[i].price = price;
items[i].count = count;
items[i].pos = pos;
if (sockets) {
for (int k = 0; k < ITEM_SOCKET_SLOT_MAX_NUM; ++k) {
items[i].sockets[k] = sockets[k];
}
}
if (attrs) {
for (int k = 0; k < ITEM_ATTRIBUTE_SLOT_MAX_NUM; ++k) {
items[i].attrs[k] = attrs[k];
}
}
}
return;
}
if (vnum != 0) {
items.push_back({vnum, price, count, pos});
ShopEntry entry;
entry.vnum = vnum;
entry.price = price;
entry.count = count;
entry.pos = pos;
if (sockets) {
for (int k = 0; k < ITEM_SOCKET_SLOT_MAX_NUM; ++k) {
entry.sockets[k] = sockets[k];
}
}
if (attrs) {
for (int k = 0; k < ITEM_ATTRIBUTE_SLOT_MAX_NUM; ++k) {
entry.attrs[k] = attrs[k];
}
}
items.push_back(std::move(entry));
}
};
update(m_shop_items);
if (!m_shop_tabs.empty()) {
update(m_shop_tabs[0].items);
}
m_shop_slot_updates.push_back(pos);
m_shop_dirty = true;
}
@@ -896,6 +958,7 @@ void EntityStore::mut_shop_close() {
m_shop_vid = 0;
m_shop_items.clear();
m_shop_tabs.clear();
m_shop_slot_updates.clear();
m_shop_selling_price = 0;
m_shop_dirty = true;
}
@@ -1581,6 +1644,7 @@ void EntityStore::apply(uint16_t header, const void *body, uint16_t len) {
e.z = (float)p.z;
e.is_main = true;
e.moving = false;
e.state_queue.clear();
m_main_vid = p.vid;
m_changes.push_back({created ? ChangeKind::Spawn : ChangeKind::MainSet, p.vid});
if (!created) {
@@ -1596,6 +1660,11 @@ void EntityStore::apply(uint16_t header, const void *body, uint16_t len) {
std::memcpy(&p, body, sizeof(p));
bool created;
Entity &e = touch(p.vid, created);
if (p.vid == m_main_vid) {
e.is_main = true;
e.moving = false;
e.state_queue.clear();
}
e.race = p.race;
e.ch_type = p.type;
e.angle = p.angle;
@@ -1616,6 +1685,11 @@ void EntityStore::apply(uint16_t header, const void *body, uint16_t len) {
std::memcpy(&p, body, sizeof(p));
bool created;
Entity &e = touch(p.vid, created);
if (p.vid == m_main_vid) {
e.is_main = true;
e.moving = false;
e.state_queue.clear();
}
e.race = p.race;
e.ch_type = p.type;
e.angle = p.angle;
@@ -1701,9 +1775,12 @@ void EntityStore::apply(uint16_t header, const void *body, uint16_t len) {
}
GCMove p;
std::memcpy(&p, body, sizeof(p));
if (p.vid == m_main_vid) {
return;
}
// CLIENT-GAP §3.2: same TCP state queue as the classic backend.
// m2dev heading is rot*360/256.
mut_move(p.vid, (float)p.rot * (360.0f / 256.0f), p.func, (float)p.x, (float)p.y,
// Server sends (int)GetRotation() / 5 (char.cpp SendMovePacket), client decodes rot * 5.0f.
mut_move(p.vid, (float)p.rot * 5.0f, p.func, (float)p.x, (float)p.y,
p.duration, p.arg, p.time);
return;
}
@@ -2056,7 +2133,14 @@ void EntityStore::apply(uint16_t header, const void *body, uint16_t len) {
if (it != m_ents.end()) {
Entity &e = it->second;
switch (p.type) {
case POINT_HP: e.hp = p.value; break;
case POINT_HP:
e.hp = p.value;
if (e.hp <= 0) {
e.dead = true;
e.moving = false;
e.func = FUNC_WAIT;
}
break;
case POINT_MAX_HP: e.max_hp = p.value; break;
case POINT_SP: e.sp = p.value; break;
case POINT_MAX_SP: e.max_sp = p.value; break;
@@ -3578,7 +3662,7 @@ void EntityStore::tick() {
m_ticked = true;
for (auto &kv : m_ents) {
Entity &e = kv.second;
if (!e.moving) {
if (!e.moving || e.is_main) {
continue;
}
const float speed = motion_move_speed(e);
@@ -3596,8 +3680,12 @@ void EntityStore::set_motion_speed(uint32_t vid, float walk_cm_s, float run_cm_s
dbg_ignored("set_motion_speed", vid);
return;
}
it->second.walk_motion_speed = walk_cm_s > 0.0f ? walk_cm_s : 0.0f;
it->second.run_motion_speed = run_cm_s > 0.0f ? run_cm_s : 0.0f;
float w = walk_cm_s > 0.0f ? walk_cm_s : 0.0f;
float r = run_cm_s > 0.0f ? run_cm_s : 0.0f;
if (w <= 0.0f && r > 0.0f) w = r;
if (r <= 0.0f && w > 0.0f) r = w;
it->second.walk_motion_speed = w;
it->second.run_motion_speed = r;
}
// Reference CActorInstance::Move(): SetLoopMotion(m_isWalking ? WALK : RUN, 0.15,
@@ -3608,7 +3696,10 @@ void EntityStore::set_motion_speed(uint32_t vid, float walk_cm_s, float run_cm_s
// the on-foot motions, so a mounted actor (horse motion set not measured) also
// uses the lerp.
float EntityStore::motion_move_speed(const Entity &e) {
const float base = e.walk_mode == WALKMODE_WALK ? e.walk_motion_speed : e.run_motion_speed;
float base = e.walk_mode == WALKMODE_WALK ? e.walk_motion_speed : e.run_motion_speed;
if (base <= 0.0f) {
base = e.walk_mode == WALKMODE_WALK ? e.run_motion_speed : e.walk_motion_speed;
}
if (base <= 0.0f || e.moving_speed > 1100 || e.mount_vnum != 0) {
return 0.0f;
}
@@ -3655,31 +3746,47 @@ void EntityStore::advance_walk_by_motion(Entity &e, float speed, uint32_t dt_ms)
const float cur_len = std::sqrt(cx * cx + cy * cy);
const float next_len = std::sqrt(nx * nx + ny * ny);
if (total - cur_len < -MOVE_OVERSHOOT_CM) {
// latency overran Dst: re-source here, turn back toward Dst, and stop at
// the next arrival instead of carrying on with FUNC_MOVE.
e.sx = e.x;
e.sy = e.y;
e.angle = heading_deg(e.tx - e.x, e.ty - e.y);
if (e.mov_after_func == FUNC_MOVE) {
e.mov_after_func = FUNC_WAIT;
}
} else if (cur_len <= total && total <= next_len) {
if (e.dead || e.knock_down) {
e.moving = false;
e.skip_collision = false;
if (e.ch_type != 0) {
// Non-PC (monsters/NPCs): stop cleanly upon reaching Dst, snap to (tx, ty),
// and never turn 180° back-and-forth across latency/overshoot.
if ((cur_len <= total && total <= next_len) || (total - cur_len < 0.0f)) {
if (e.dead || e.knock_down) {
e.moving = false;
e.skip_collision = false;
return;
}
e.x = e.tx;
e.y = e.ty;
finish_walk(e, true);
return;
}
if (e.mov_after_func == FUNC_MOVE) {
// FUNC_MOVE: keep walking past Dst until the next state command (or
// the overshoot guard above) — this is what keeps a moving remote PC
// from stop-starting between its periodic move packets.
e.x = next_x;
e.y = next_y;
} else {
if (total - cur_len < -MOVE_OVERSHOOT_CM) {
// latency overran Dst: re-source here, turn back toward Dst, and stop at
// the next arrival instead of carrying on with FUNC_MOVE.
e.sx = e.x;
e.sy = e.y;
e.angle = heading_deg(e.tx - e.x, e.ty - e.y);
if (e.mov_after_func == FUNC_MOVE) {
e.mov_after_func = FUNC_WAIT;
}
} else if (cur_len <= total && total <= next_len) {
if (e.dead || e.knock_down) {
e.moving = false;
e.skip_collision = false;
return;
}
if (e.mov_after_func == FUNC_MOVE) {
// FUNC_MOVE: keep walking past Dst until the next state command (or
// the overshoot guard above) — this is what keeps a moving remote PC
// from stop-starting between its periodic move packets.
e.x = next_x;
e.y = next_y;
return;
}
finish_walk(e, true);
return;
}
finish_walk(e, true);
return;
}
e.x = next_x;
e.y = next_y;
+12 -2
View File
@@ -68,7 +68,7 @@ static constexpr uint32_t MOVE_MAX_TICK_MS = 250;
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, ...
uint8_t ch_type = 0; // normalized CHRTYPE: 0 PC, 1 NPC, 2 MONSTER, 3 STONE, ...
std::string name;
std::string shop_sign; // GC_SHOP_SIGN; empty when the private shop is closed
uint16_t parts[CHR_EQUIPPART_NUM] = {0, 0, 0, 0};
@@ -102,6 +102,7 @@ struct Entity {
// __EnableSkipCollision(): a walk kicked off by a network state packet ignores
// actor-vs-actor collision until it arrives.
std::deque<StateCmd> state_queue;
int32_t average_network_gap = 0; // 40250 CInstanceBase::m_nAverageNetworkGap EMA filter
uint8_t mov_after_func = FUNC_WAIT;
uint8_t mov_after_arg = 0;
float mov_after_rot = 0;
@@ -305,6 +306,8 @@ struct ShopEntry {
uint32_t price = 0;
uint8_t count = 0;
uint8_t pos = 0; // slot index within its tab (0..SHOP_HOST_ITEM_MAX_NUM-1)
int32_t sockets[ITEM_SOCKET_SLOT_MAX_NUM] = {0, 0, 0};
ItemAttr attrs[ITEM_ATTRIBUTE_SLOT_MAX_NUM] = {};
};
// One shelf/tab of a SHOP_GC_START_EX shop. A plain SHOP_GC_START shop is
@@ -653,7 +656,8 @@ public:
// --- NPC shop (GC_SHOP) --- a single-shelf shop is one unnamed tab.
void mut_shop_open(uint32_t vid, const std::vector<ShopTab> &tabs);
void mut_shop_update_item(uint8_t pos, uint32_t vnum, uint32_t price, uint8_t count);
void mut_shop_update_item(uint8_t pos, uint32_t vnum, uint32_t price, uint8_t count,
const int32_t *sockets = nullptr, const ItemAttr *attrs = nullptr);
void mut_shop_update_price(int32_t price);
void mut_shop_close();
void mut_shop_error(const std::string &code);
@@ -858,6 +862,11 @@ public:
m_shop_errors.clear();
return v;
}
std::vector<uint8_t> drain_shop_slot_updates() {
auto v = std::move(m_shop_slot_updates);
m_shop_slot_updates.clear();
return v;
}
// --- P8 exchange ---
const ExchangeState &exchange() const { return m_exchange; }
@@ -1293,6 +1302,7 @@ private:
bool m_shop_dirty = false;
int32_t m_shop_selling_price = 0;
std::vector<std::string> m_shop_errors;
std::vector<uint8_t> m_shop_slot_updates;
ExchangeState m_exchange;
bool m_exchange_dirty = false;
Item m_safebox[SAFEBOX_MAX_NUM];
+73 -31
View File
@@ -284,12 +284,16 @@ void M2Client::_bind_methods() {
ADD_SIGNAL(MethodInfo("friend_invite_ask", PropertyInfo(Variant::STRING, "name")));
ADD_SIGNAL(MethodInfo("shop_opened", PropertyInfo(Variant::INT, "vid")));
ADD_SIGNAL(MethodInfo("shop_closed"));
ADD_SIGNAL(MethodInfo("shop_updated", PropertyInfo(Variant::INT, "pos")));
ADD_SIGNAL(MethodInfo("shop_error", PropertyInfo(Variant::STRING, "kind")));
ADD_SIGNAL(MethodInfo("exchange_changed"));
ADD_SIGNAL(MethodInfo("safebox_opened", PropertyInfo(Variant::INT, "size")));
ADD_SIGNAL(MethodInfo("safebox_closed"));
ADD_SIGNAL(MethodInfo("safebox_changed"));
ADD_SIGNAL(MethodInfo("safebox_password_required"));
ADD_SIGNAL(MethodInfo("safebox_wrong_password"));
ADD_SIGNAL(MethodInfo("mall_opened", PropertyInfo(Variant::INT, "size")));
ADD_SIGNAL(MethodInfo("mall_closed"));
ADD_SIGNAL(MethodInfo("mall_changed"));
ADD_SIGNAL(MethodInfo("mall_password_required"));
ADD_SIGNAL(MethodInfo("private_shop_open_requested"));
@@ -530,6 +534,7 @@ void M2Client::connect_to_server(const String &auth_host, int auth_port, const S
classic_last_empire = -1;
shop_open_seen = false;
mall_open_seen = false;
safebox_open_seen = false;
last_game_phase = -1;
if (!classic_sess->connect(std::string(auth_host.utf8().get_data()), (uint16_t)auth_port,
std::string(g_host.utf8().get_data()), (uint16_t)g_port,
@@ -580,6 +585,7 @@ void M2Client::disconnect_from_server() {
}
shop_open_seen = false;
mall_open_seen = false;
safebox_open_seen = false;
selected_pid = 0;
if (stage != Stage::Idle) {
set_stage(Stage::Idle);
@@ -1277,6 +1283,19 @@ static Dictionary shop_entry_dict(const mtnet::ShopEntry &s) {
d["vnum"] = (int)s.vnum;
d["price"] = (int)s.price;
d["count"] = (int)s.count;
Array sockets;
for (int k = 0; k < mtnet::ITEM_SOCKET_SLOT_MAX_NUM; ++k) {
sockets.push_back(s.sockets[k]);
}
d["sockets"] = sockets;
Array attrs;
for (int k = 0; k < mtnet::ITEM_ATTRIBUTE_SLOT_MAX_NUM; ++k) {
Dictionary ad;
ad["type"] = (int)s.attrs[k].type;
ad["value"] = (int)s.attrs[k].value;
attrs.push_back(ad);
}
d["attrs"] = attrs;
return d;
}
@@ -1466,6 +1485,31 @@ int M2Client::get_safebox_gold() const {
const mtnet::EntityStore *world = active_world();
return world ? (int)world->safebox_gold() : 0;
}
static Dictionary item_dict(const mtnet::Item &it) {
Dictionary d;
d["vnum"] = (int)it.vnum;
d["count"] = (int)it.count;
d["flags"] = (int)it.flags;
d["anti_flags"] = (int)it.anti_flags;
Array sockets;
for (int i = 0; i < 3; ++i) {
sockets.push_back(it.sockets[i]);
}
d["sockets"] = sockets;
Array attrs;
for (int i = 0; i < 7; ++i) {
if (it.attrs[i].type == 0) {
continue;
}
Dictionary a;
a["type"] = (int)it.attrs[i].type;
a["value"] = (int)it.attrs[i].value;
attrs.push_back(a);
}
d["attrs"] = attrs;
return d;
}
Array M2Client::get_safebox_items() const {
Array out;
const mtnet::EntityStore *world = active_world();
@@ -1477,10 +1521,8 @@ Array M2Client::get_safebox_items() const {
if (it.empty()) {
continue;
}
Dictionary d;
Dictionary d = item_dict(it);
d["cell"] = c;
d["vnum"] = (int)it.vnum;
d["count"] = (int)it.count;
out.push_back(d);
}
return out;
@@ -1507,10 +1549,8 @@ Array M2Client::get_mall_items() const {
if (it.empty()) {
continue;
}
Dictionary d;
Dictionary d = item_dict(it);
d["cell"] = c;
d["vnum"] = (int)it.vnum;
d["count"] = (int)it.count;
out.push_back(d);
}
return out;
@@ -2476,7 +2516,7 @@ static Dictionary entity_dict(const mtnet::Entity &e) {
Dictionary d;
d["vid"] = (int)e.vid;
d["race"] = (int)e.race;
d["ch_type"] = (int)e.ch_type; // 0 PC, 1 NPC, 2 MONSTER, ...
d["ch_type"] = (int)e.ch_type; // normalized: 0 PC, 1 NPC, 2 MONSTER, ...
d["name"] = String::utf8(e.name.c_str());
d["shop_sign"] = String::utf8(e.shop_sign.c_str());
d["is_main"] = e.is_main;
@@ -2701,30 +2741,6 @@ bool M2Client::pickup_item(int ground_vid) {
return game && game->send_item_pickup((uint32_t)ground_vid);
}
static Dictionary item_dict(const mtnet::Item &it) {
Dictionary d;
d["vnum"] = (int)it.vnum;
d["count"] = (int)it.count;
d["flags"] = (int)it.flags;
d["anti_flags"] = (int)it.anti_flags;
Array sockets;
for (int i = 0; i < 3; ++i) {
sockets.push_back(it.sockets[i]);
}
d["sockets"] = sockets;
Array attrs;
for (int i = 0; i < 7; ++i) {
if (it.attrs[i].type == 0) {
continue;
}
Dictionary a;
a["type"] = (int)it.attrs[i].type;
a["value"] = (int)it.attrs[i].value;
attrs.push_back(a);
}
d["attrs"] = attrs;
return d;
}
Array M2Client::get_inventory() const {
Array out;
@@ -2930,6 +2946,7 @@ void M2Client::warp_to_game_server(const String &host, int port) {
classic_last_empire = -1;
shop_open_seen = false;
mall_open_seen = false;
safebox_open_seen = false;
dead_seen.clear();
classic_sess = std::make_unique<mtnet::classic::ClassicSession>();
classic_sess->set_wire_trace(OS::get_singleton()->get_environment("MT_NET_TRACE") == "1");
@@ -3299,6 +3316,9 @@ void M2Client::pump_classic() {
emit_signal("shop_closed");
}
}
for (uint8_t pos : w.drain_shop_slot_updates()) {
emit_signal("shop_updated", (int)pos);
}
for (const auto &err : w.drain_shop_errors()) {
emit_signal("shop_error", String::utf8(err.c_str()));
}
@@ -3325,12 +3345,21 @@ void M2Client::pump_classic() {
emit_signal("exchange_changed");
}
if (w.safebox_dirty()) {
bool safe_now = w.safebox_open();
if (safe_now && !safebox_open_seen) {
emit_signal("safebox_opened", w.safebox_size());
} else if (!safe_now && safebox_open_seen) {
emit_signal("safebox_closed");
}
safebox_open_seen = safe_now;
emit_signal("safebox_changed");
}
if (w.mall_dirty()) {
bool mall_now = w.mall_open();
if (mall_now && !mall_open_seen) {
emit_signal("mall_opened", w.mall_size());
} else if (!mall_now && mall_open_seen) {
emit_signal("mall_closed");
}
mall_open_seen = mall_now;
emit_signal("mall_changed");
@@ -3433,6 +3462,7 @@ void M2Client::pump_classic() {
classic_last_stage = -1;
shop_open_seen = false;
mall_open_seen = false;
safebox_open_seen = false;
dead_seen.clear();
set_stage(Stage::GameConnect);
return;
@@ -3826,6 +3856,9 @@ void M2Client::pump_game() {
emit_signal("shop_closed");
}
}
for (uint8_t pos : w.drain_shop_slot_updates()) {
emit_signal("shop_updated", (int)pos);
}
for (const auto &err : w.drain_shop_errors()) {
emit_signal("shop_error", String::utf8(err.c_str()));
}
@@ -3833,12 +3866,21 @@ void M2Client::pump_game() {
emit_signal("exchange_changed");
}
if (w.safebox_dirty()) {
bool safe_now = w.safebox_open();
if (safe_now && !safebox_open_seen) {
emit_signal("safebox_opened", w.safebox_size());
} else if (!safe_now && safebox_open_seen) {
emit_signal("safebox_closed");
}
safebox_open_seen = safe_now;
emit_signal("safebox_changed");
}
if (w.mall_dirty()) {
bool mall_now = w.mall_open();
if (mall_now && !mall_open_seen) {
emit_signal("mall_opened", w.mall_size());
} else if (!mall_now && mall_open_seen) {
emit_signal("mall_closed");
}
mall_open_seen = mall_now;
emit_signal("mall_changed");
+1
View File
@@ -370,6 +370,7 @@ private:
bool enter_game_sent = false;
bool shop_open_seen = false; // last shop_open() state we emitted a signal for
bool mall_open_seen = false;
bool safebox_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
+9 -1
View File
@@ -49,7 +49,11 @@ Ref<StandardMaterial3D> material_for(const std::string &tex_name, bool alpha_ble
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);
mat->set_cull_mode(StandardMaterial3D::CULL_DISABLED);
mat->set_diffuse_mode(StandardMaterial3D::DIFFUSE_LAMBERT_WRAP);
mat->set_specular_mode(StandardMaterial3D::SPECULAR_DISABLED);
mat->set_feature(StandardMaterial3D::FEATURE_BACKLIGHT, true);
mat->set_backlight(Color(0.2f, 0.2f, 0.2f));
String t = String(tex_name.c_str()).to_lower();
bool kw_alpha = t.find("leaf") != -1 || t.find("grass") != -1 || t.find("fence") != -1 ||
@@ -78,6 +82,10 @@ Ref<StandardMaterial3D> material_for(const std::string &tex_name, bool alpha_ble
if (tex.is_valid()) {
mat->set_texture(StandardMaterial3D::TEXTURE_ALBEDO, tex);
mat->set_albedo(Color(1, 1, 1));
mat->set_feature(StandardMaterial3D::FEATURE_EMISSION, true);
mat->set_texture(StandardMaterial3D::TEXTURE_EMISSION, tex);
mat->set_emission(Color(1.0f, 1.0f, 1.0f));
mat->set_emission_energy_multiplier(0.18f);
}
return mat;
}
+6 -3
View File
@@ -228,10 +228,13 @@ int main() {
mc.y = 200000;
mc.z = 3;
mc.empire = 1;
mc.skill_group = 2;
feed(s, raw(mc));
CHECK(s.world().main_vid() == 7777, "main vid set");
const auto *me = s.world().get(7777);
CHECK(me && me->is_main && me->x == 100000.f && me->name == "Warrior", "main entity");
CHECK(s.world().skill_group() == 2, "skill_group propagated to world");
CHECK(s.parser().empire() == 1, "empire propagated to parser");
auto version = drain(s);
// §1.5: EUROPE-family locale sends CG_CLIENT_VERSION2 (0xf1) with the
// fixed ymir epoch string; same 67-byte layout as CG_CLIENT_VERSION.
@@ -248,7 +251,7 @@ int main() {
add.header = HDR_GC_CHARACTER_ADD;
add.vid = 8888;
add.race = 101;
add.type = CHRTYPE_NPC;
add.type = WIRE_CHRTYPE_NPC;
add.x = 100500;
add.y = 200500;
add.moving_speed = 150;
@@ -279,7 +282,7 @@ int main() {
mob.header = HDR_GC_CHARACTER_ADD;
mob.vid = 8890;
mob.race = 101;
mob.type = CHRTYPE_MONSTER;
mob.type = WIRE_CHRTYPE_MONSTER;
mob.x = 101000;
mob.y = 201000;
feed(s, raw(mob));
@@ -292,7 +295,7 @@ int main() {
ghost.header = HDR_GC_CHARACTER_ADD;
ghost.vid = 8891;
ghost.race = 20025;
ghost.type = CHRTYPE_MONSTER;
ghost.type = WIRE_CHRTYPE_MONSTER;
feed(s, raw(ghost));
CHECK(s.world().get(8891) == nullptr && s.parser().pending_actor_count() == 0 &&
s.world().size() == 3, "invisible race dropped");
+31 -3
View File
@@ -14,6 +14,7 @@
#include "../src/net/classic/classic_session.h"
#include <chrono>
#include <array>
#include <cstdio>
#include <cstdlib>
#include <string>
@@ -74,7 +75,8 @@ int main(int argc, char **argv) {
const int enter_delay_ms = std::getenv("MT_CLASSIC_ENTER_DELAY") ?
std::atoi(std::getenv("MT_CLASSIC_ENTER_DELAY")) : 8000;
session.set_auto_entergame_delay(enter_delay_ms);
session.set_wire_trace(std::getenv("MT_NET_TRACE") != nullptr);
session.set_wire_trace(std::getenv("MT_NET_TRACE") != nullptr &&
std::string(std::getenv("MT_NET_TRACE")) == "1");
const bool use_auth = std::getenv("MT_CLASSIC_AUTH") != nullptr;
const bool use_direct_enter = std::getenv("MT_CLASSIC_DIRECT") != nullptr;
const int auth_port = std::getenv("MT_CLASSIC_AUTH_PORT") ?
@@ -140,12 +142,25 @@ int main(int argc, char **argv) {
}
if (session.stage() == mtnet::INetSession::Stage::InGame) {
const auto &world = session.world();
std::array<int, 10> entity_types{};
for (uint32_t vid : world.vids()) {
if (const auto *entity = world.get(vid); entity && entity->ch_type < entity_types.size()) {
++entity_types[entity->ch_type];
}
}
int inventory_count = 0;
for (int cell = 0; cell < mtnet::INVENTORY_MAX_NUM; ++cell) {
if (!world.inv_slot(cell).empty()) ++inventory_count;
}
std::printf("PASS PHASE_GAME main_vid=%u entities=%zu inventory_slots=%d points_level=%d\n",
world.main_vid(), world.size(), inventory_count, world.points().level());
std::printf("PASS PHASE_GAME main_vid=%u entities=%zu inventory_slots=%d points_level=%d skill_group=%u\n",
world.main_vid(), world.size(), inventory_count, world.points().level(),
(unsigned)world.skill_group());
std::printf(" entity_types pc=%d npc=%d monster=%d stone=%d warp=%d door=%d building=%d horse=%d goto=%d\n",
entity_types[mtnet::classic::CHRTYPE_PC], entity_types[mtnet::classic::CHRTYPE_NPC],
entity_types[mtnet::classic::CHRTYPE_MONSTER], entity_types[mtnet::classic::CHRTYPE_STONE],
entity_types[mtnet::classic::CHRTYPE_WARP], entity_types[mtnet::classic::CHRTYPE_DOOR],
entity_types[mtnet::classic::CHRTYPE_BUILDING], entity_types[mtnet::classic::CHRTYPE_HORSE],
entity_types[mtnet::classic::CHRTYPE_GOTO]);
if (const mtnet::Entity *me = world.get(world.main_vid())) {
std::printf(" main race=%u pos_cm=(%.0f,%.0f,%.0f) parts=[%u,%u,%u,%u] name=%s\n",
me->race, me->x, me->y, me->z, me->parts[0], me->parts[1], me->parts[2],
@@ -158,6 +173,19 @@ int main(int argc, char **argv) {
(unsigned)item.count);
}
}
for (int pos = 0; pos < mtnet::QUICKSLOT_MAX_NUM; ++pos) {
const mtnet::QuickSlot slot = world.quickslot(pos);
if (slot.type != 0) {
std::printf(" quickslot pos=%d type=%u ref=%u\n", pos,
(unsigned)slot.type, (unsigned)slot.position);
}
}
for (int id = 0; id < mtnet::SKILL_MAX_NUM; ++id) {
if (world.skill_level(id) != 0) {
std::printf(" skill id=%d level=%u master=%u\n", id,
(unsigned)world.skill_level(id), (unsigned)world.skill_master(id));
}
}
session.disconnect();
return 0;
}