完善客户端功能并同步差距文档

This commit is contained in:
shenlei
2026-09-03 18:40:01 +09:00
parent f93a172296
commit 13ccb8d02d
135 changed files with 21881 additions and 1259 deletions
+290 -3
View File
@@ -1,8 +1,16 @@
#include "environment_builder.h"
#include "asset_io.h"
#include "dxt.h"
#include <godot_cpp/classes/environment.hpp>
#include <godot_cpp/classes/image.hpp>
#include <godot_cpp/classes/image_texture.hpp>
#include <godot_cpp/classes/procedural_sky_material.hpp>
#include <godot_cpp/classes/shader.hpp>
#include <godot_cpp/classes/shader_material.hpp>
#include <godot_cpp/classes/sky.hpp>
#include <godot_cpp/classes/texture2d.hpp>
#include <godot_cpp/core/object.hpp>
#include <m2_coord.h>
@@ -18,9 +26,158 @@ namespace {
Color rgba(const fmt::Rgba &c) { return Color(c[0], c[1], c[2], c[3]); }
float luma(const fmt::Rgba &c) { return 0.2126f * c[0] + 0.7152f * c[1] + 0.0722f * c[2]; }
const char *SRC_SKYBOX = R"(shader_type sky;
render_mode use_debanding;
uniform sampler2D front_tex : source_color, filter_linear_mipmap_anisotropic;
uniform sampler2D back_tex : source_color, filter_linear_mipmap_anisotropic;
uniform sampler2D left_tex : source_color, filter_linear_mipmap_anisotropic;
uniform sampler2D right_tex : source_color, filter_linear_mipmap_anisotropic;
uniform sampler2D top_tex : source_color, filter_linear_mipmap_anisotropic;
uniform sampler2D bottom_tex : source_color, filter_linear_mipmap_anisotropic;
uniform bool has_front = false;
uniform bool has_back = false;
uniform bool has_left = false;
uniform bool has_right = false;
uniform bool has_top = false;
uniform bool has_bottom = false;
uniform vec4 gradient_top = vec4(0.2, 0.3, 0.6, 1.0);
uniform vec4 gradient_horizon = vec4(0.5, 0.6, 0.8, 1.0);
uniform vec4 gradient_bottom = vec4(0.2, 0.2, 0.25, 1.0);
uniform sampler2D cloud_tex : source_color, repeat_enable, filter_linear_mipmap_anisotropic;
uniform bool has_cloud = false;
uniform vec2 cloud_scale = vec2(2000.0, 2000.0);
uniform float cloud_height = 300.0;
uniform vec2 cloud_texture_scale = vec2(4.0, 4.0);
uniform vec2 cloud_speed = vec2(0.001, 0.001);
uniform vec4 cloud_tint = vec4(0.0, 0.0, 0.0, 0.0);
vec3 gradient_color(vec3 direction) {
float height = clamp(direction.y * 0.5 + 0.5, 0.0, 1.0);
if (height < 0.5)
return mix(gradient_bottom.rgb, gradient_horizon.rgb, height * 2.0);
return mix(gradient_horizon.rgb, gradient_top.rgb, (height - 0.5) * 2.0);
}
vec3 apply_cloud(vec3 color, vec3 direction) {
if (!has_cloud || direction.y <= 0.001 || cloud_height <= 0.0 ||
cloud_scale.x <= 0.001 || cloud_scale.y <= 0.001)
return color;
// ClientVS22 renders a finite horizontal quad at camera.z + CloudHeight.
// After the Metin2 -> Godot conversion this is camera.y + height. The
// source quad maps source +Y -> U=0 and source +X -> V=1; source Y is
// Godot -Z, so keep that orientation here.
float distance_to_cloud = cloud_height / direction.y;
vec2 plane = direction.xz * distance_to_cloud;
if (abs(plane.x) > cloud_scale.x || abs(plane.y) > cloud_scale.y)
return color;
vec2 uv = vec2(
0.5 + 0.5 * plane.y / cloud_scale.y,
0.5 + 0.5 * plane.x / cloud_scale.x);
uv = fract(uv * cloud_texture_scale + TIME * cloud_speed);
// SkyBox.cpp uses MODULATEINVALPHA_ADDCOLOR followed by ONE /
// INVSRCCOLOR blending: cloud.rgb + sky * (1 - cloud.rgb).
vec4 texel = texture(cloud_tex, uv);
vec3 cloud_rgb = clamp(texel.rgb * (1.0 - cloud_tint.a) + cloud_tint.rgb,
0.0, 1.0);
return cloud_rgb + color * (1.0 - cloud_rgb);
}
void sky() {
vec3 direction = normalize(EYEDIR);
vec3 color = gradient_color(direction);
float ax = abs(direction.x);
float ay = abs(direction.y);
float az = abs(direction.z);
vec2 uv;
// ClientVS22 uses Metin2 Z-up faces. After x,z,-y conversion:
// front=+Z, back=-Z, left=+X, right=-X, top=+Y, bottom=-Y.
if (az >= ax && az >= ay) {
if (direction.z > 0.0) {
uv = vec2(0.5 - 0.5 * direction.x / az,
0.5 - 0.5 * direction.y / az);
if (has_front)
color = texture(front_tex, uv).rgb;
} else {
uv = vec2(0.5 + 0.5 * direction.x / az,
0.5 - 0.5 * direction.y / az);
if (has_back)
color = texture(back_tex, uv).rgb;
}
} else if (ax >= ay) {
if (direction.x > 0.0) {
uv = vec2(0.5 + 0.5 * direction.z / ax,
0.5 - 0.5 * direction.y / ax);
if (has_left)
color = texture(left_tex, uv).rgb;
} else {
uv = vec2(0.5 - 0.5 * direction.z / ax,
0.5 - 0.5 * direction.y / ax);
if (has_right)
color = texture(right_tex, uv).rgb;
}
} else if (direction.y > 0.0) {
uv = vec2(0.5 - 0.5 * direction.x / ay,
0.5 - 0.5 * direction.z / ay);
if (has_top)
color = texture(top_tex, uv).rgb;
} else {
uv = vec2(0.5 - 0.5 * direction.x / ay,
0.5 - 0.5 * direction.z / ay);
if (has_bottom)
color = texture(bottom_tex, uv).rgb;
}
COLOR = apply_cloud(color, direction);
}
)";
Ref<ImageTexture> solid_texture(const Color &color) {
PackedByteArray pixels;
pixels.resize(4);
uint8_t *p = pixels.ptrw();
p[0] = (uint8_t)std::round(std::clamp(color.r, 0.0f, 1.0f) * 255.0f);
p[1] = (uint8_t)std::round(std::clamp(color.g, 0.0f, 1.0f) * 255.0f);
p[2] = (uint8_t)std::round(std::clamp(color.b, 0.0f, 1.0f) * 255.0f);
p[3] = (uint8_t)std::round(std::clamp(color.a, 0.0f, 1.0f) * 255.0f);
Ref<godot::Image> image = godot::Image::create_from_data(
1, 1, false, godot::Image::FORMAT_RGBA8, pixels);
image->generate_mipmaps();
return ImageTexture::create_from_image(image);
}
Ref<ImageTexture> load_texture(const std::string &name, const fmt::AssetResolver *resolver) {
if (name.empty() || resolver == nullptr)
return Ref<ImageTexture>();
const std::string path = resolver->resolve(name, nullptr);
if (path.empty())
return Ref<ImageTexture>();
Ref<godot::Image> image;
const String godot_path(path.c_str());
if (godot_path.get_extension().to_lower() == "dds") {
const mtgodot::Image decoded = mtgodot::dds_from_file(godot_path);
if (!decoded.ok())
return Ref<ImageTexture>();
PackedByteArray pixels;
pixels.resize((int64_t)decoded.rgba.size());
std::copy(decoded.rgba.begin(), decoded.rgba.end(), pixels.ptrw());
image = godot::Image::create_from_data(decoded.w, decoded.h, false,
godot::Image::FORMAT_RGBA8, pixels);
} else {
image = godot::Image::load_from_file(godot_path);
}
if (image.is_null() || image->is_empty())
return Ref<ImageTexture>();
image->generate_mipmaps();
return ImageTexture::create_from_image(image);
}
} // namespace
EnvNodes apply_environment(const fmt::Environment &env, Node *parent) {
EnvNodes apply_environment(const fmt::Environment &env, Node *parent,
const fmt::AssetResolver *resolver) {
EnvNodes out;
// --- DirectionalLight (Background) ---
@@ -30,6 +187,8 @@ EnvNodes apply_environment(const fmt::Environment &env, Node *parent) {
out.sun->set_name("Sun");
parent->add_child(out.sun);
}
out.sun->set_cull_mask(1u << 0); // background / terrain layer
out.sun->set_visible(env.dir_light.bg_enable);
// .msenv Direction 是 Metin2 Z-up 向量(光传播方向)。
fmt::m2coord::Vec3 d = fmt::m2coord::direction_to_godot(
env.dir_light.direction[0], env.dir_light.direction[1], env.dir_light.direction[2]);
@@ -56,6 +215,26 @@ EnvNodes apply_environment(const fmt::Environment &env, Node *parent) {
// 角色/物体的间接补光:暖色,能量取自 Material.Ambient
out.sun->set_param(Light3D::PARAM_SPECULAR, 0.4f);
// ClientVS22 keeps the character light separate from the background light
// (MapOutdoor::OnBeginEnvironment -> SpeedTree::SetLight). Godot's light
// cull mask is the closest exact scene-level equivalent: static world
// geometry is layer 1, Metin2Model geometry is layer 2.
out.character_light = Object::cast_to<DirectionalLight3D>(
parent->get_node_or_null(NodePath("CharacterLight")));
if (!out.character_light) {
out.character_light = memnew(DirectionalLight3D);
out.character_light->set_name("CharacterLight");
parent->add_child(out.character_light);
}
out.character_light->set_cull_mask(1u << 1);
out.character_light->set_visible(env.dir_light.ch_enable);
out.character_light->set_transform(Transform3D(b, Vector3(0, 0, 0)));
out.character_light->set_color(rgba(env.dir_light.ch_diffuse));
out.character_light->set_param(Light3D::PARAM_ENERGY,
std::clamp(0.9f + luma(env.dir_light.ch_diffuse) * 0.4f, 0.7f, 1.6f));
out.character_light->set_shadow(false);
out.character_light->set_param(Light3D::PARAM_SPECULAR, 0.4f);
// --- WorldEnvironment ---
out.world_env =
Object::cast_to<WorldEnvironment>(parent->get_node_or_null(NodePath("WorldEnv")));
@@ -68,7 +247,12 @@ EnvNodes apply_environment(const fmt::Environment &env, Node *parent) {
if (e.is_null())
e.instantiate();
// 天空:SkyBox Gradient -> ProceduralSky 的三段色(zenith / horizon / ground
// 天空:SkyBox Gradient -> ProceduralSky 的三段色(zenith / horizon / ground
// 参考端的 texture mode 是独立六面 quadGodot Sky shader 用 EYEDIR 采样
// 同一套六面图,坐标和 UV 依照 EterLib/SkyBox.cpp 的面顶点顺序转换。
int sky_face_count = 0;
bool sky_texture_applied = false;
bool sky_cloud_applied = false;
{
Ref<ProceduralSkyMaterial> psm;
psm.instantiate();
@@ -83,7 +267,79 @@ EnvNodes apply_environment(const fmt::Environment &env, Node *parent) {
psm->set_sun_angle_max(6.0f);
Ref<Sky> sky;
sky.instantiate();
sky->set_material(psm);
std::array<Ref<ImageTexture>, 6> faces;
std::array<bool, 6> face_loaded{};
for (int i = 0; i < 6; ++i) {
if (env.sky.face_textures[i].empty())
continue;
faces[i] = load_texture(env.sky.face_textures[i], resolver);
face_loaded[i] = faces[i].is_valid();
if (face_loaded[i])
++sky_face_count;
}
Ref<ImageTexture> cloud_texture;
if (!env.sky.cloud_texture.empty())
cloud_texture = load_texture(env.sky.cloud_texture, resolver);
const bool use_texture_sky = env.sky.texture_render_mode && sky_face_count > 0;
sky_texture_applied = use_texture_sky;
if (use_texture_sky) {
Ref<Shader> shader;
shader.instantiate();
shader->set_code(String(SRC_SKYBOX));
Ref<ShaderMaterial> material;
material.instantiate();
material->set_shader(shader);
Color fallback = g.empty() ? Color(0.35f, 0.45f, 0.7f, 1.0f) : rgba(g.front());
Color horizon = g.size() < 2 ? fallback : rgba(g[g.size() / 2]);
Color ground = g.empty() ? Color(0.2f, 0.2f, 0.25f, 1.0f) : rgba(g.back());
material->set_shader_parameter("gradient_top", fallback);
material->set_shader_parameter("gradient_horizon", horizon);
material->set_shader_parameter("gradient_bottom", ground);
static constexpr const char *kFaceParams[6] = {
"front_tex", "back_tex", "left_tex", "right_tex", "top_tex", "bottom_tex"};
static constexpr const char *kFaceFlags[6] = {
"has_front", "has_back", "has_left", "has_right", "has_top", "has_bottom"};
for (int i = 0; i < 6; ++i) {
if (!faces[i].is_valid())
faces[i] = solid_texture(i == 5 ? ground : horizon);
material->set_shader_parameter(kFaceParams[i], faces[i]);
material->set_shader_parameter(kFaceFlags[i], face_loaded[i]);
}
if (cloud_texture.is_valid()) {
material->set_shader_parameter("cloud_tex", cloud_texture);
material->set_shader_parameter("has_cloud", true);
material->set_shader_parameter("cloud_scale", Vector2(
std::max(env.sky.cloud_scale[0] * (float)fmt::m2coord::CM_TO_M, 0.01f),
std::max(env.sky.cloud_scale[1] * (float)fmt::m2coord::CM_TO_M, 0.01f)));
material->set_shader_parameter("cloud_height",
std::max(env.sky.cloud_height * (float)fmt::m2coord::CM_TO_M, 0.01f));
material->set_shader_parameter("cloud_texture_scale", Vector2(
env.sky.cloud_texture_scale[0], env.sky.cloud_texture_scale[1]));
material->set_shader_parameter("cloud_speed", Vector2(
env.sky.cloud_speed[0], env.sky.cloud_speed[1]));
Color tint = Color(0, 0, 0, 0);
if (!env.sky.cloud_color.empty())
tint = rgba(env.sky.cloud_color.front());
material->set_shader_parameter("cloud_tint", tint);
sky_cloud_applied = true;
}
sky->set_material(material);
} else {
sky->set_material(psm);
}
// Reference SkyBox scrolls clouds in its own renderer. Godot's procedural
// sky has no equivalent UV-speed control, but sky_cover preserves the real
// cloud asset and alpha/color instead of silently dropping CloudTextureFileName.
if (!env.sky.cloud_texture.empty() && !use_texture_sky) {
if (cloud_texture.is_valid()) {
psm->set_sky_cover(cloud_texture);
Color tint = Color(1, 1, 1, 1);
if (!env.sky.cloud_color.empty())
tint = rgba(env.sky.cloud_color.front());
psm->set_sky_cover_modulate(tint);
sky_cloud_applied = true;
}
}
e->set_sky(sky);
e->set_background(godot::Environment::BG_SKY);
}
@@ -145,6 +401,37 @@ EnvNodes apply_environment(const fmt::Environment &env, Node *parent) {
e->set_ssao_radius(1.2f);
e->set_ssao_intensity(0.9f);
// Keep parsed reference values visible to the runtime/debug probe even where
// Godot has no 1:1 mapping (six cube faces, D3D blend factors, cloud motion,
// and lens flare). This also prevents a future builder from silently losing
// the fields while the renderer equivalent is being implemented.
out.world_env->set_meta("msenv_sky_texture_mode", env.sky.texture_render_mode);
out.world_env->set_meta("msenv_sky_face_count", sky_face_count);
out.world_env->set_meta("msenv_sky_texture_mode_applied", sky_texture_applied);
out.world_env->set_meta("msenv_sky_cloud_applied", sky_cloud_applied);
out.world_env->set_meta("msenv_sky_scale",
Vector3(env.sky.scale[0], env.sky.scale[1], env.sky.scale[2]));
out.world_env->set_meta("msenv_cloud_scale",
Vector2(env.sky.cloud_scale[0], env.sky.cloud_scale[1]));
out.world_env->set_meta("msenv_cloud_height", env.sky.cloud_height);
out.world_env->set_meta("msenv_cloud_texture_scale",
Vector2(env.sky.cloud_texture_scale[0], env.sky.cloud_texture_scale[1]));
out.world_env->set_meta("msenv_cloud_speed",
Vector2(env.sky.cloud_speed[0], env.sky.cloud_speed[1]));
out.world_env->set_meta("msenv_filter_enabled", env.filter.enable);
out.world_env->set_meta("msenv_filter_color", rgba(env.filter.color));
out.world_env->set_meta("msenv_filter_alpha_src", env.filter.alpha_src);
out.world_env->set_meta("msenv_filter_alpha_dest", env.filter.alpha_dest);
out.world_env->set_meta("msenv_lens_flare_enabled", env.lens_flare.enable);
out.world_env->set_meta("msenv_lens_flare_texture", env.lens_flare.main_flare_texture.c_str());
out.world_env->set_meta("msenv_background_light_enabled", env.dir_light.bg_enable);
out.world_env->set_meta("msenv_character_light_enabled", env.dir_light.ch_enable);
out.world_env->set_meta("msenv_background_light_ambient", rgba(env.dir_light.bg_ambient));
out.world_env->set_meta("msenv_character_light_ambient", rgba(env.dir_light.ch_ambient));
// MapUtil.cpp defaults; Environment_Load has no wind token to override these.
out.world_env->set_meta("msenv_wind_strength", 0.2f);
out.world_env->set_meta("msenv_wind_random", 0.0f);
out.world_env->set_environment(e);
return out;
}
+8 -3
View File
@@ -3,19 +3,24 @@
#include <godot_cpp/classes/directional_light3d.hpp>
#include <godot_cpp/classes/world_environment.hpp>
#include <asset_resolver.h>
#include <environment.h>
// W5 —— .msenv(已由 formats/environment 解析)-> Godot 光照 / 天空 / 雾 / 色调。
// SHINSOO §9-W5。DirectionalLight.Background 驱动场景主光;Character 光留待角色材质
// uniform(§9-W5 note)。云 / lens flare 留 R2。
// SHINSOO §9-W5。背景 / 角色方向光按参考端分成两个可见性层;云贴图接入
// ProceduralSkyMaterial 的 sky covertexture-mode 下再用 Godot sky shader 叠加
// 参考端的有限云层平面与 UV 滚动。六面天空、滤色和 lens flare 仍保持显式待办,
// 不把不等价的近似标成完成。
namespace mtgodot {
struct EnvNodes {
godot::DirectionalLight3D *sun = nullptr;
godot::DirectionalLight3D *character_light = nullptr;
godot::WorldEnvironment *world_env = nullptr;
};
// 在 parent 下建 / 配 DirectionalLight3D + WorldEnvironment。已存在则复用。
EnvNodes apply_environment(const fmt::Environment &env, godot::Node *parent);
EnvNodes apply_environment(const fmt::Environment &env, godot::Node *parent,
const fmt::AssetResolver *resolver = nullptr);
} // namespace mtgodot
+87
View File
@@ -17,6 +17,9 @@
#include <godot_cpp/classes/file_access.hpp>
#include <godot_cpp/variant/array.hpp>
#include <godot_cpp/variant/dictionary.hpp>
#include <godot_cpp/variant/packed_int32_array.hpp>
#include <combo_table.h>
using namespace godot;
@@ -47,6 +50,11 @@ void Metin2AnimPlayer::_bind_methods() {
ClassDB::bind_method(D_METHOD("get_accumulation"), &Metin2AnimPlayer::get_accumulation);
ClassDB::bind_method(D_METHOD("get_events"), &Metin2AnimPlayer::get_events);
ClassDB::bind_method(D_METHOD("get_loop_data"), &Metin2AnimPlayer::get_loop_data);
ClassDB::bind_method(D_METHOD("get_motion_data"), &Metin2AnimPlayer::get_motion_data);
ClassDB::bind_static_method("Metin2AnimPlayer",
D_METHOD("parse_combo_tables", "py_path"), &Metin2AnimPlayer::parse_combo_tables);
ClassDB::bind_static_method("Metin2AnimPlayer",
D_METHOD("make_combo_key", "motion_mode", "combo_type"), &Metin2AnimPlayer::make_combo_key);
// Fired when playback time crosses a .msa MotionEventData entry.
ADD_SIGNAL(MethodInfo("motion_event",
@@ -187,6 +195,85 @@ Dictionary Metin2AnimPlayer::get_loop_data() const {
return d;
}
// CLIENT-GAP §3.3 / §3.5 — the currently-loaded .msa's ComboInputData / AttackingData.
// `next_combo` is the reference普攻节奏 (CRaceMotionData::GetNextComboTime); when the
// motion has no ComboInputData it falls back to MotionDuration * 0.9.
Dictionary Metin2AnimPlayer::get_motion_data() const {
const fmt::Msa &m = msa_metadata;
Dictionary d;
d["duration"] = duration > 0.0 ? duration : m.duration;
d["has_combo_input"] = m.has_combo_input;
d["pre_input_time"] = m.combo_pre_input_time;
d["direct_input_time"] = m.combo_direct_input_time;
d["input_limit_time"] = m.combo_input_limit_time;
d["link_time"] = m.combo_link_time;
const double dur = duration > 0.0 ? duration : m.duration;
d["next_combo"] = m.has_combo_input && m.combo_direct_input_time > 0.0
? (double)m.combo_direct_input_time
: dur * 0.9;
d["has_attacking_data"] = m.has_attacking_data;
d["attacking_type"] = m.attacking_type;
d["motion_type"] = m.motion_type;
d["hitting_type"] = m.hitting_type;
d["attack_start_time"] = m.attack_start_time;
d["attack_end_time"] = m.attack_end_time;
d["stiffen_time"] = m.stiffen_time;
d["invisible_time"] = m.invisible_time;
d["external_force"] = m.external_force;
d["hit_limit_count"] = m.hit_limit_count;
// CLIENT-GAP §3.5 修改 1 —— THitDataContainer 多命中窗(多段挥击 / 双持 = 多个窗)。
// [{start_time, end_time, bone, weapon_length, samples:[{time, last_pos:Vector3, pos:Vector3}]}]
Array windows;
for (const fmt::Msa::HitWindow &w : m.hit_windows) {
Dictionary wd;
wd["start_time"] = w.start_time;
wd["end_time"] = w.end_time;
wd["bone"] = String::utf8(w.bone_name.c_str());
wd["weapon_length"] = w.weapon_length;
Array samples;
for (const fmt::Msa::HitSample &s : w.samples) {
Dictionary sd;
sd["time"] = s.time;
sd["last_pos"] = Vector3(s.last_pos[0], s.last_pos[1], s.last_pos[2]);
sd["pos"] = Vector3(s.pos[0], s.pos[1], s.pos[2]);
samples.push_back(sd);
}
wd["samples"] = samples;
windows.push_back(wd);
}
d["hit_windows"] = windows;
return d;
}
int Metin2AnimPlayer::make_combo_key(int motion_mode, int combo_type) {
return (int)fmt::make_combo_key((uint16_t)motion_mode, (uint16_t)combo_type);
}
Dictionary Metin2AnimPlayer::parse_combo_tables(const String &py_path) {
Dictionary out;
fmt::PlayerComboTables tables;
std::string err;
if (!fmt::parse_player_combo_tables_file(
std::string(py_path.utf8().get_data()), tables, &err)) {
UtilityFunctions::push_warning(
String("Metin2AnimPlayer.parse_combo_tables: ") + String(err.c_str()));
return out;
}
for (int c = 0; c < fmt::COMBO_CLASS_COUNT; ++c) {
Dictionary cls;
for (const auto &kv : tables.klass(c).combos) {
PackedInt32Array segs;
segs.resize((int)kv.second.size());
for (int i = 0; i < (int)kv.second.size(); ++i) {
segs.set(i, (int)kv.second[i]);
}
cls[(int)kv.first] = segs;
}
out[c] = cls;
}
return out;
}
void Metin2AnimPlayer::_ready() {
set_process(true);
reload();
+10
View File
@@ -68,6 +68,16 @@ public:
godot::Vector3 get_accumulation() const { return accumulation; }
godot::Array get_events() const; // [{type,start_time,effect,sound,pos}, ...]
godot::Dictionary get_loop_data() const;
godot::Dictionary get_motion_data() const; // {duration,next_combo,attack_start_time,...}
// CLIENT-GAP §3.5 — parse the PC combo tables out of playersettingmodule.py
// (chrmgr.ReserveComboAttackNew / RegisterComboAttackNew → CRaceData). Returns
// { class_idx:int -> { combo_key:int -> PackedInt32Array(段号 14..21) } }
// with class_idx 0=warrior 1=assassin 2=sura 3=shaman and
// combo_key = (motion_mode << 16) | combo_type. Empty Dictionary on failure.
static godot::Dictionary parse_combo_tables(const godot::String &py_path);
// (motion_mode << 16) | combo_type — mirrors RaceData.h MAKE_COMBO_KEY.
static int make_combo_key(int motion_mode, int combo_type);
// NaN-scan every animation in anim_path across [0,dur]; returns a report string.
godot::String selfcheck(int samples = 24);
+3
View File
@@ -265,6 +265,7 @@ void Metin2Model::_set_lod(int n) {
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_layer_mask(1u << 1);
lod_prev_mi->set_mesh(mi->get_mesh());
add_child(lod_prev_mi);
lod_prev_mi->set_transform(mi->get_transform());
@@ -457,6 +458,7 @@ void Metin2Model::reload() {
if (mesh->get_surface_count() > 0) {
mi = memnew(MeshInstance3D);
mi->set_name("MeshInstance3D");
mi->set_layer_mask(1u << 1); // CharacterLight visibility layer.
mi->set_mesh(mesh);
add_child(mi);
mi->set_owner(get_owner() ? get_owner() : this);
@@ -722,6 +724,7 @@ void Metin2Model::_load_attach(const String &gr2_rel, const String &bone_name,
slot_mi = memnew(MeshInstance3D);
slot_mi->set_name(node_name);
slot_mi->set_layer_mask(1u << 1); // equipment follows the character light.
slot_mi->set_mesh(wmesh);
add_child(slot_mi);
+133 -26
View File
@@ -45,6 +45,7 @@
#include <algorithm>
#include <cctype>
#include <cmath>
#include <cstdlib>
using namespace godot;
@@ -90,9 +91,14 @@ void Metin2World::_bind_methods() {
ClassDB::bind_method(D_METHOD("sample_height", "gx_m", "gz_m"), &Metin2World::sample_height);
ClassDB::bind_method(D_METHOD("sample_attribute", "gx_m", "gz_m"), &Metin2World::sample_attribute);
ClassDB::bind_method(D_METHOD("is_blocked", "gx_m", "gz_m"), &Metin2World::is_blocked);
ClassDB::bind_method(D_METHOD("get_fishing_rotation", "gx_m", "gz_m", "heading_deg"),
&Metin2World::get_fishing_rotation);
ClassDB::bind_method(D_METHOD("can_fishing_position", "gx_m", "gz_m", "heading_deg"),
&Metin2World::can_fishing_position);
ClassDB::bind_method(D_METHOD("load_dds", "path"), &Metin2World::load_dds);
ClassDB::bind_method(D_METHOD("chunk_dir", "tx", "ty"), &Metin2World::chunk_dir);
ClassDB::bind_method(D_METHOD("get_load_report"), &Metin2World::get_load_report);
ClassDB::bind_method(D_METHOD("get_ambience_sources"), &Metin2World::get_ambience_sources);
ClassDB::bind_method(D_METHOD("bake_asset_index", "out_path"), &Metin2World::bake_asset_index);
ADD_PROPERTY(PropertyInfo(Variant::STRING, "assets_root", PROPERTY_HINT_GLOBAL_DIR),
@@ -248,6 +254,7 @@ bool Metin2World::build_chunk(int tx, int ty) {
MeshInstance3D *pm = memnew(MeshInstance3D);
pm->set_name(Pn > 1 ? vformat("Terrain_%d_%d", pi, pj) : String("Terrain"));
pm->set_layer_mask(1u << 0); // Background light visibility layer.
pm->set_mesh(pmesh);
if (Pn > 1 && terrain_patch_view > 0.0f) {
// 硬剔除(无半透明淡出),避免远景地形变透明
@@ -299,6 +306,7 @@ bool Metin2World::build_chunk(int tx, int ty) {
continue;
MeshInstance3D *w = memnew(MeshInstance3D);
w->set_name("Water");
w->set_layer_mask(1u << 0);
w->set_mesh(p.mesh);
croot->add_child(w);
++water_pieces;
@@ -314,6 +322,8 @@ bool Metin2World::build_chunk(int tx, int ty) {
ck.root = croot;
if (objects_enabled && registry_ok && resolver)
place_chunk_objects(tx, ty, croot, ck.objects, ck.trees);
if (registry_ok)
place_chunk_ambience(tx, ty);
objects_placed += ck.objects;
trees_placed += ck.trees;
chunks.push_back(std::move(ck));
@@ -400,6 +410,7 @@ void Metin2World::place_chunk_objects(int tx, int ty, Node3D *root, int &n_obj,
MeshInstance3D *mi = memnew(MeshInstance3D);
mi->set_name(String(p->name.c_str()) + "_" + String::num_uint64(o.crc));
mi->set_layer_mask(1u << 0); // Background light visibility layer.
mi->set_mesh(mesh);
mi->set_transform(xform);
// ShadowFlag: 客户端把 isShadowFlag 物体丢进动态阴影贴图(= 我们的实时投影);
@@ -462,6 +473,7 @@ void Metin2World::place_chunk_objects(int tx, int ty, Node3D *root, int &n_obj,
mm->set_instance_transform(i, kv.second.xforms[i]);
MultiMeshInstance3D *mmi = memnew(MultiMeshInstance3D);
mmi->set_name(String("Trees_") + String(kv.first.c_str()).get_file().get_basename());
mmi->set_layer_mask(1u << 0);
mmi->set_multimesh(mm);
// §3.3: 树影已烘焙进 shadowmap.dds;proxy 几何的实时投影形状也不对 -> 默认不投。
mmi->set_cast_shadows_setting(tree_shadows ? GeometryInstance3D::SHADOW_CASTING_SETTING_ON
@@ -474,6 +486,46 @@ void Metin2World::place_chunk_objects(int tx, int ty, Node3D *root, int &n_obj,
}
}
void Metin2World::place_chunk_ambience(int tx, int ty) {
// Area ambience is a separate file in ClientVS22. The .pra property owns
// play type / interval / sound names; Audio owns the runtime instances.
const std::string mdir = std::string(map_dir().utf8().get_data());
fmt::AreaAmbienceData ad;
std::string e;
const std::string base = mdir + "/" + fmt::m2coord::tile_dir(tx, ty) + "/";
bool loaded = fmt::parse_area_ambience_file(base + "areaambiencedata.txt", ad, &e);
if (!loaded)
loaded = fmt::parse_area_ambience_file(base + "AreaAmbienceData.txt", ad, &e);
if (!loaded)
return; // 老地图可能没有环境声文件。
for (size_t i = 0; i < ad.objects.size(); ++i) {
const fmt::AreaAmbienceObject &o = ad.objects[i];
const fmt::Property *p = registry.find(o.crc);
if (!p || p->type != fmt::PropertyType::Ambience)
continue;
AmbienceSource source;
source.tile_x = tx;
source.tile_y = ty;
source.object_index = (int)i;
const fmt::m2coord::Vec3 g = fmt::m2coord::position_to_godot(o.x, o.y, o.z);
source.position = Vector3((float)g.x, (float)g.y, (float)g.z);
source.range_cm = std::max(0, (int)std::lround(o.range));
source.max_volume_area_percentage = (float)std::strtof(
p->get("maxvolumeareapercentage", "0").c_str(), nullptr);
source.play_interval = (float)std::strtof(
p->get("playinterval", "0").c_str(), nullptr);
source.play_interval_variation = (float)std::strtof(
p->get("playintervalvariation", "0").c_str(), nullptr);
source.play_type = p->get("playtype", "LOOP").c_str();
const std::string sounds = p->get("ambiencesoundvector");
if (!sounds.empty())
source.sounds.push_back(sounds);
if (!source.sounds.empty() && source.range_cm > 0)
ambience_sources.push_back(std::move(source));
}
}
bool Metin2World::load_map() {
unload_map();
@@ -487,39 +539,35 @@ bool Metin2World::load_map() {
}
setting_ok = true;
// splat 前置:TextureSet + AssetResolver(整盘扫描,一次)
// AssetResolver 是地图对象、环境声属性和 splat 共用的资源索引。
// 即使关闭 splat,也要保留这条链路,避免环境声依赖渲染开关。
splat_ready = false;
if (splat_enabled) {
const std::string root = std::string(assets_root.utf8().get_data());
resolver = std::make_shared<fmt::AssetResolver>();
std::string re;
// asset_index.txt 存在就装载(PCK/移动端必走),否则扫盘(桌面开发)。
if (!resolver->build_or_load(root, fmt::AssetResolver::default_priority(), &re)) {
UtilityFunctions::push_warning(String("[Metin2World] AssetResolver: ") + re.c_str());
resolver.reset();
}
if (splat_enabled && resolver) {
std::string ts_rel = setting.texture_set;
for (char &c : ts_rel) {
if (c == '\\')
c = '/';
c = (char)std::tolower((unsigned char)c);
}
const std::string root = std::string(assets_root.utf8().get_data());
std::string tse;
if (fmt::parse_texture_set_file(root + "/textureset/" + ts_rel, texture_set, &tse)) {
resolver = std::make_shared<fmt::AssetResolver>();
std::string re;
// asset_index.txt 存在就装载(PCK/移动端必走),否则扫盘(桌面开发)。
if (resolver->build_or_load(root, fmt::AssetResolver::default_priority(), &re)) {
splat_ready = true;
} else {
UtilityFunctions::push_warning(String("[Metin2World] AssetResolver: ") + re.c_str());
resolver.reset();
}
} else {
if (fmt::parse_texture_set_file(root + "/textureset/" + ts_rel, texture_set, &tse))
splat_ready = true;
else
UtilityFunctions::push_warning(String("[Metin2World] TextureSet: ") + tse.c_str());
}
}
// Property CRC 注册表(一次;splat 已建 resolver
// Property CRC 注册表(对象和环境声共用;不依赖 objects_enabled)。
registry_ok = false;
if (objects_enabled) {
const std::string root(assets_root.utf8().get_data());
std::string re;
// resolver 已建(splat 阶段):用它的文件清单,避免再 std::filesystem 扫盘
// PCK 里扫不了)。没 resolver 时退回目录递归。
{
// resolver 已建:用它的文件清单,避免再 std::filesystem 扫盘(PCK 里扫不了)。
bool ok = resolver
? registry.scan_list(root, resolver->all_rel(), &re)
: registry.scan(root + "/Property", &re);
@@ -554,12 +602,9 @@ bool Metin2World::load_map() {
if (rp.empty())
rp = std::string(assets_root.utf8().get_data()) +
"/ETC/ymir work/environment/" + setting.environment;
// 小写文件名
for (size_t i = rp.rfind('/') + 1; i < rp.size(); ++i)
rp[i] = (char)std::tolower((unsigned char)rp[i]);
std::string ee;
if (fmt::parse_environment_file(rp, env, &ee)) {
apply_environment(env, this);
apply_environment(env, this, resolver.get());
env_ok = true;
} else {
UtilityFunctions::push_warning(String("[Metin2World] .msenv: ") + ee.c_str());
@@ -588,6 +633,9 @@ void Metin2World::unload_chunk(int idx) {
--chunks_built;
if (c.root)
c.root->queue_free();
ambience_sources.erase(std::remove_if(ambience_sources.begin(), ambience_sources.end(),
[&c](const AmbienceSource &s) { return s.tile_x == c.tx && s.tile_y == c.ty; }),
ambience_sources.end());
chunks.erase(chunks.begin() + idx);
}
@@ -637,11 +685,12 @@ void Metin2World::unload_map() {
while (!chunks.empty())
unload_chunk((int)chunks.size() - 1);
stream_queue.clear();
ambience_sources.clear();
if (objects_root) {
objects_root->queue_free();
objects_root = nullptr;
}
for (const char *nm : {"Sun", "WorldEnv"})
for (const char *nm : {"Sun", "CharacterLight", "WorldEnv"})
if (Node *n = get_node_or_null(NodePath(nm)))
n->queue_free();
water_pieces = 0;
@@ -736,6 +785,42 @@ int Metin2World::sample_attribute(double gx_m, double gz_m) const {
return c->am->data[size_t(ay) * fmt::ATTRMAP_XY + ax];
}
double Metin2World::get_fishing_rotation(double gx_m, double gz_m, double heading_deg) const {
// InstanceBaseMotion.cpp uses c_fFishingDistance=600.0f and
// ELPlainCoord_GetRotatedPixelPosition (sin(rot), cos(rot)). The POC
// world frame is the MapCoord frame, where heading -> world yaw is
// heading + 90 degrees; using the same conversion keeps the probe in
// the coordinate frame used by player_controller / MapCoord.
constexpr double DISTANCE_M = 6.0;
constexpr double WATER_ATTRIBUTE = 1 << 1; // PRTerrainLib::ATTRIBUTE_WATER
constexpr double DEG_TO_RAD = 3.14159265358979323846 / 180.0;
auto normalized = [](double d) {
d = std::fmod(d, 360.0);
return d < 0.0 ? d + 360.0 : d;
};
auto water_at = [&](double candidate_heading) {
const double yaw = (candidate_heading + 90.0) * DEG_TO_RAD;
const double x = gx_m + std::sin(yaw) * DISTANCE_M;
const double z = gz_m + std::cos(yaw) * DISTANCE_M;
return (sample_attribute(x, z) & int(WATER_ATTRIBUTE)) != 0;
};
for (int step = 0; step <= 18; ++step) {
const double offset = double(step * 10);
const double right = heading_deg + offset;
if (water_at(right))
return normalized(right);
const double left = heading_deg - offset;
if (water_at(left))
return normalized(left);
}
return -1.0;
}
bool Metin2World::can_fishing_position(double gx_m, double gz_m, double heading_deg) const {
return get_fishing_rotation(gx_m, gz_m, heading_deg) >= 0.0;
}
Dictionary Metin2World::get_load_report() const {
Dictionary d;
d["map_path"] = map_path;
@@ -756,6 +841,7 @@ Dictionary Metin2World::get_load_report() const {
d["objects_placed"] = objects_placed;
d["objects_skipped"] = objects_skipped;
d["objects_missing_model"] = objects_missing_model;
d["ambience_sources"] = (int)ambience_sources.size();
d["trees_placed"] = trees_placed;
d["tree_species"] = tree_species;
d["objects_mdatr_pending"] = objects_mdatr_pending; // 有 .mdatr 但未建碰撞
@@ -768,6 +854,27 @@ Dictionary Metin2World::get_load_report() const {
return d;
}
Array Metin2World::get_ambience_sources() const {
Array out;
for (const AmbienceSource &s : ambience_sources) {
Dictionary d;
d["key"] = String::num_int64(s.tile_x) + ":" + String::num_int64(s.tile_y) + ":" +
String::num_int64(s.object_index);
d["position"] = s.position;
d["range_cm"] = s.range_cm;
d["max_volume_area_percentage"] = s.max_volume_area_percentage;
d["play_interval"] = s.play_interval;
d["play_interval_variation"] = s.play_interval_variation;
d["play_type"] = s.play_type;
Array sounds;
for (const std::string &sound : s.sounds)
sounds.push_back(String(sound.c_str()));
d["sounds"] = sounds;
out.push_back(d);
}
return out;
}
Dictionary Metin2World::get_perf() const {
Dictionary d;
Performance *pf = Performance::get_singleton();
+22
View File
@@ -3,6 +3,7 @@
#include <godot_cpp/classes/image.hpp>
#include <godot_cpp/classes/node3d.hpp>
#include <godot_cpp/classes/ref.hpp>
#include <godot_cpp/variant/array.hpp>
#include <godot_cpp/variant/dictionary.hpp>
#include <godot_cpp/variant/string.hpp>
@@ -84,7 +85,15 @@ public:
// attr.atr 属性字节(bit0=BLOCK, bit1=WATER…);地图外返回 0。
int sample_attribute(double gx_m, double gz_m) const;
bool is_blocked(double gx_m, double gz_m) const { return (sample_attribute(gx_m, gz_m) & 1) != 0; }
// ClientVS22 InstanceBaseMotion::GetFishingRot 等价:在角色前方 600cm
// 按当前 heading ±(0..180°,步长 10°) 扫描 ATTRIBUTE_WATER。
// 返回选中的服务端 heading;没有水面或目标区块未加载时返回 -1。
double get_fishing_rotation(double gx_m, double gz_m, double heading_deg) const;
bool can_fishing_position(double gx_m, double gz_m, double heading_deg) const;
godot::Dictionary get_load_report() const;
// Area ambience sources from AreaAmbienceData + .pra properties. Audio owns
// playback; the world only exposes the reference source records.
godot::Array get_ambience_sources() const;
// 便捷:解一张 DDS 为 Image(HUD 小地图等用;Godot 原生不支持 .dds)。
godot::Ref<godot::Image> load_dds(const godot::String &path) const;
@@ -144,13 +153,26 @@ private:
godot::Node3D *root = nullptr; // 该区块的全部场景节点(terrain + water + 对象 + 树)
int objects = 0, trees = 0;
};
struct AmbienceSource {
int tile_x = 0, tile_y = 0;
int object_index = 0;
godot::Vector3 position;
int range_cm = 0;
float max_volume_area_percentage = 0.0f;
float play_interval = 0.0f;
float play_interval_variation = 0.0f;
godot::String play_type;
std::vector<std::string> sounds;
};
std::vector<Chunk> chunks;
std::vector<std::pair<int, int>> stream_queue; // 待建区块
std::vector<AmbienceSource> ambience_sources;
int stream_budget = 1; // 每帧最多建几个区块(streaming 时)
godot::String map_dir() const;
bool build_chunk(int tx, int ty);
void place_chunk_objects(int tx, int ty, godot::Node3D *root, int &n_obj, int &n_tree);
void place_chunk_ambience(int tx, int ty);
void unload_chunk(int idx);
void stream_update();
const Chunk *chunk_at(int tx, int ty) const;
+17 -3
View File
@@ -271,6 +271,9 @@ bool ClassicParser::on_gc(uint8_t header, const uint8_t *body, uint32_t len) {
(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,
p.alignment, p.pk_mode, p.mount_vnum);
// §2.5 (W1↔W2 G1 wiring): affect flags travel in their own two-word
// segment, not through mut_char_info.
m_world.mut_affect_flags(p.vid, p.affect_flag[0], p.affect_flag[1]);
return true;
}
case HDR_GC_CHARACTER_DEL: { // 2
@@ -286,9 +289,11 @@ bool ClassicParser::on_gc(uint8_t header, const uint8_t *body, uint32_t len) {
if (!fill(p, header, body, len)) {
return false;
}
// classic wire heading: deg = bRot * 5 (client NetStream Send/Recv Move)
// classic wire heading: deg = bRot * 5 (client NetStream Send/Recv Move).
// §3.2: p.arg is the motion index, p.time the server command time that
// gates when the queued state command is released.
m_world.mut_move(p.vid, (float)p.rot * 5.0f, p.func, (float)p.x, (float)p.y,
p.duration);
p.duration, p.arg, p.time);
return true;
}
case HDR_GC_CHARACTER_POINTS: { // 16
@@ -367,6 +372,8 @@ bool ClassicParser::on_gc(uint8_t header, const uint8_t *body, uint32_t len) {
}
m_world.mut_char_update(p.vid, p.parts, p.moving_speed, p.attack_speed,
(int32_t)p.guild_id, p.alignment, p.pk_mode, p.mount_vnum);
// §2.5 (W1↔W2 G1 wiring): mut_char_update carries no affect segment.
m_world.mut_affect_flags(p.vid, p.affect_flag[0], p.affect_flag[1]);
return true;
}
case HDR_GC_CHARACTER_POSITION: { // 43
@@ -687,9 +694,16 @@ bool ClassicParser::on_gc(uint8_t header, const uint8_t *body, uint32_t len) {
m_world.mut_exchange_start(p.arg1);
break;
case EXCHANGE_GC_ITEM_ADD:
{
ItemAttr attrs[ITEM_ATTRIBUTE_MAX_NUM]{};
for (int i = 0; i < ITEM_ATTRIBUTE_MAX_NUM; ++i) {
attrs[i].type = p.attrs[i].type;
attrs[i].value = p.attrs[i].value;
}
m_world.mut_exchange_item(self, static_cast<uint8_t>(p.arg2.cell), p.arg1,
static_cast<uint8_t>(p.arg3));
static_cast<uint8_t>(p.arg3), p.sockets, attrs);
break;
}
case EXCHANGE_GC_ITEM_DEL:
m_world.mut_exchange_item_del(self, static_cast<uint8_t>(p.arg1));
break;
@@ -301,6 +301,10 @@ void ClassicSession::pump() {
set_stage(Stage::Failed);
}
}
// §3.2 — feed the server frame clock (from the GC_HANDSHAKE lDelta exchange)
// so EntityStore::drain_state_queue() gates queued StateCmds on server time
// instead of releasing immediately. 0 until the first handshake lands.
m_world.set_server_frame_ms(m_stream.server_frame_ms());
m_world.tick();
}
@@ -45,6 +45,16 @@ void ClassicStream::set_state(State s) {
}
}
uint32_t ClassicStream::server_frame_ms() const {
// §3.2 — mirror EterLib ELTimer_GetServerFrameMSec(): the handshake stored
// (m_server_time_base, m_client_time_base); the server clock advances with
// local wall time from there. 0 until the first handshake lands.
if (m_client_time_base == 0) {
return 0;
}
return now_ms() - m_client_time_base + m_server_time_base;
}
// --------------------------------------------------------------------- socket
bool ClassicStream::connect(const std::string &host, uint16_t port) {
disconnect();
@@ -99,6 +99,14 @@ public:
bool cipher_active() const { return m_cipher.activated(); }
void set_polarity(bool client) { m_polarity = client; }
// §3.2 — ELTimer_GetServerFrameMSec() equivalent. The GC_HANDSHAKE exchange
// (handle_control HDR_HANDSHAKE) stores m_server_time_base = hs.time + lDelta
// and m_client_time_base = local now; the server frame clock is then
// local_now - m_client_time_base + m_server_time_base. Returns 0 until the
// first handshake has landed (EntityStore treats 0 as "release immediately").
uint32_t server_frame_ms() const;
bool has_server_clock() const { return m_client_time_base != 0; }
private:
void set_state(State s);
bool recv_into_buffer();
+184 -34
View File
@@ -1,6 +1,7 @@
#include "entity_store.h"
#include <algorithm>
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <cstring>
@@ -65,25 +66,6 @@ void EntityStore::mark_vitals(uint32_t vid) {
m_vitals.push_back(vid);
}
void EntityStore::start_move(Entity &e, float tx, float ty, uint32_t start_ms, uint32_t dur_ms,
uint8_t func) {
e.func = func;
if (func == FUNC_MOVE && dur_ms > 0) {
e.moving = true;
e.sx = e.x;
e.sy = e.y;
e.tx = tx;
e.ty = ty;
e.move_start_ms = start_ms;
e.move_dur_ms = dur_ms;
} else {
// non-move func (attack / wait / skill / instant): snap to the target
e.moving = false;
e.x = tx;
e.y = ty;
}
}
// --- protocol-neutral mutation API (m2dev apply() + classic parser share it) ---
void EntityStore::mut_spawn(uint32_t vid, uint16_t race, uint8_t ch_type, const std::string &name,
@@ -196,18 +178,153 @@ void EntityStore::mut_despawn(uint32_t vid) {
}
}
// CLIENT-GAP §3.2: GC_MOVE no longer mutates position directly. It appends a
// StateCmd to the entity's TCP state queue (reference CInstanceBase::PushTCPState)
// and then drains anything already due, so a command whose chk_time is in the
// 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) {
uint32_t dur_ms, uint8_t arg, uint32_t chk_time) {
auto it = m_ents.find(vid);
if (it == m_ents.end()) {
return;
}
Entity &e = it->second;
e.angle = angle_deg;
start_move(e, tx, ty, m_now, dur_ms, func);
e.state_queue.push_back({chk_time, tx, ty, angle_deg, func, arg, dur_ms});
drain_state_queue(e);
m_changes.push_back({ChangeKind::Move, vid});
}
// ~ CInstanceBase::__CanProcessNetworkStatePacket: a dead or knocked-down actor
// holds its queue (the pending commands stay, they just don't fire yet).
bool EntityStore::can_process_network_state(const Entity &e) {
return !e.dead && !e.knock_down;
}
// ~ CInstanceBase::__IsEnableTCPProcess: the reference blocks TCP processing
// during an un-cancellable emote. We have no emote-lock state on the row yet, so
// this is always true for now (documented deferral, §3.2).
bool EntityStore::is_enable_tcp_process(const Entity &, uint8_t) {
return true;
}
// Kick off a walk toward the command's Dst (reference NEW_SetSrcPixelPosition /
// NEW_SetDstPixelPosition + StartWalking). Records the "after arrival" action and
// enables skip-collision for the duration of the walk.
void EntityStore::begin_state_walk(Entity &e, const StateCmd &c, uint8_t after_func) {
const float dx = c.dst_x - e.x;
const float dy = c.dst_y - e.y;
const float dist = std::sqrt(dx * dx + dy * dy);
// The reference data layer (SNetworkActorData::UpdatePosition) interpolates
// against the server-supplied duration; only when the server omits it do we
// derive one from distance / move speed.
uint32_t dur = c.dur_ms;
if (dur == 0) {
const float speed = e.moving_speed > 0 ? (float)e.moving_speed : 150.0f;
dur = speed > 0.0f ? (uint32_t)(dist / speed * 1000.0f) : 0;
}
if (dur == 0) {
dur = 1; // never a zero-length walk; tick() finishes it next frame
}
e.angle = c.rot;
e.moving = true;
e.func = FUNC_MOVE;
e.sx = e.x;
e.sy = e.y;
e.tx = c.dst_x;
e.ty = c.dst_y;
e.move_start_ms = m_now;
e.move_dur_ms = dur;
e.mov_after_func = after_func;
e.mov_after_arg = c.arg;
e.mov_after_rot = c.rot;
e.skip_collision = true;
}
// The reference eFunc switch (InstanceBase.cpp StateProcess), with its two hard
// thresholds: 1.0 for FUNC_WAIT (walk vs. snap-align) and 50.0 for the attack
// funcs (walk-then-act vs. snap-and-act). No lerp, no jitter buffer.
void EntityStore::apply_state_cmd(Entity &e, const StateCmd &c) {
const float dx = c.dst_x - e.x;
const float dy = c.dst_y - e.y;
const float dir_len = std::sqrt(dx * dx + dy * dy);
switch (c.func) {
case FUNC_WAIT:
if (dir_len > 1.0f) {
begin_state_walk(e, c, FUNC_WAIT);
} else {
e.moving = false;
e.x = c.dst_x;
e.y = c.dst_y;
e.angle = c.rot;
e.func = FUNC_WAIT;
e.mov_after_func = FUNC_WAIT;
e.skip_collision = false;
}
break;
case FUNC_MOVE:
begin_state_walk(e, c, FUNC_MOVE);
break;
case FUNC_COMBO:
case FUNC_ATTACK:
case FUNC_MOB_SKILL:
if (dir_len >= 50.0f) {
begin_state_walk(e, c, c.func);
} else {
e.moving = false;
e.x = c.dst_x;
e.y = c.dst_y;
e.angle = c.rot;
e.func = c.func;
e.mov_after_func = FUNC_WAIT;
e.skip_collision = false;
}
break;
default:
// FUNC_EMOTION / FUNC_SKILL|idx and anything else: snap + set func,
// same as the pre-§3.2 non-move path.
e.moving = false;
e.x = c.dst_x;
e.y = c.dst_y;
e.angle = c.rot;
e.func = c.func;
e.mov_after_func = FUNC_WAIT;
e.skip_collision = false;
break;
}
}
// Release the due prefix of one entity's queue (reference StateProcess while-loop).
void EntityStore::drain_state_queue(Entity &e) {
while (!e.state_queue.empty()) {
const StateCmd &front = e.state_queue.front();
// server-frame gate (reference: ELTimer_GetServerFrameMSec() < m_dwChkTime):
// only when the clock is aligned (non-zero). STATE_QUEUE_MAX_WAIT_MS is a
// staleness valve — a chk_time further ahead than that means the server's
// dwTime is not in our frame-clock timebase (skew / re-stamp mismatch), so
// release now rather than freeze the actor. Real-server dwTime semantics
// still pending validation (§3.2).
if (m_server_frame_ms != 0 && front.chk_time > m_server_frame_ms &&
front.chk_time - m_server_frame_ms <= STATE_QUEUE_MAX_WAIT_MS) {
break;
}
if (!can_process_network_state(e)) {
break;
}
if (!is_enable_tcp_process(e, front.func)) {
break;
}
StateCmd cmd = front;
e.state_queue.pop_front();
apply_state_cmd(e, cmd);
}
}
void EntityStore::process_states() {
for (auto &kv : m_ents) {
drain_state_queue(kv.second);
}
}
void EntityStore::mut_set_points(const int32_t *pts, int n) {
// CLIENT-GAP §2.4: GC_CHARACTER_POINTS is a full snapshot. The wire array is
// exactly POINT_MAX_NUM (255) ints; clamp a longer count and zero the tail of
@@ -444,6 +561,18 @@ void EntityStore::mut_stun(uint32_t vid) {
mark_vitals(vid);
}
void EntityStore::mut_knockdown(uint32_t vid, bool on) {
auto it = m_ents.find(vid);
if (it == m_ents.end()) {
return;
}
if (it->second.knock_down == on) {
return;
}
it->second.knock_down = on;
m_changes.push_back({ChangeKind::Info, vid});
}
void EntityStore::mut_dead(uint32_t vid) {
auto it = m_ents.find(vid);
if (it == m_ents.end()) {
@@ -1246,13 +1375,20 @@ void EntityStore::mut_exchange_start(uint32_t partner_vid) {
m_exchange_dirty = true;
}
void EntityStore::mut_exchange_item(bool self, uint8_t slot, uint32_t vnum, uint8_t count) {
void EntityStore::mut_exchange_item(bool self, uint8_t slot, uint32_t vnum, uint8_t count,
const int32_t *sockets, const ItemAttr *attrs) {
if (slot >= 12) {
return;
}
ExchangeSlot &item = self ? m_exchange.self_items[slot] : m_exchange.peer_items[slot];
item.vnum = vnum;
item.count = count;
for (int i = 0; i < ITEM_SOCKET_SLOT_MAX_NUM; ++i) {
item.sockets[i] = sockets ? sockets[i] : 0;
}
for (int i = 0; i < ITEM_ATTRIBUTE_SLOT_MAX_NUM; ++i) {
item.attrs[i] = attrs ? attrs[i] : ItemAttr{};
}
m_exchange_dirty = true;
}
@@ -1515,16 +1651,10 @@ void EntityStore::apply(uint16_t header, const void *body, uint16_t len) {
}
GCMove p;
std::memcpy(&p, body, sizeof(p));
auto it = m_ents.find(p.vid);
if (it == m_ents.end()) {
return; // move for an unknown entity
}
Entity &e = it->second;
e.angle = (float)p.rot * (360.0f / 256.0f);
// server sends absolute start time + duration; interpolate against m_now
uint32_t dur = p.duration;
start_move(e, (float)p.x, (float)p.y, m_now, dur, p.func);
m_changes.push_back({ChangeKind::Move, p.vid});
// 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,
p.duration, p.arg, p.time);
return;
}
case GC_CHARACTER_POSITION: {
@@ -2443,6 +2573,12 @@ void EntityStore::apply(uint16_t header, const void *body, uint16_t len) {
if (s) {
s->vnum = p.arg1;
s->count = (uint8_t)p.arg3;
for (int i = 0; i < ITEM_SOCKET_SLOT_MAX_NUM; ++i) {
s->sockets[i] = p.sockets[i];
}
for (int i = 0; i < ITEM_ATTRIBUTE_SLOT_MAX_NUM; ++i) {
s->attrs[i] = p.attrs[i];
}
}
break;
}
@@ -3350,6 +3486,9 @@ void EntityStore::tick() {
m_points_dirty = true;
}
}
// CLIENT-GAP §3.2: release any TCP state commands that have come due, then
// advance the walk. A queued command released here starts its walk this tick.
process_states();
for (auto &kv : m_ents) {
Entity &e = kv.second;
if (!e.moving) {
@@ -3365,7 +3504,18 @@ void EntityStore::tick() {
e.x = e.tx;
e.y = e.ty;
e.moving = false;
e.func = FUNC_WAIT;
e.skip_collision = false;
// reference m_kMovAfterFunc: once the walk reaches Dst, run the
// action the state packet asked for (COMBO / ATTACK / MOB_SKILL);
// otherwise settle to WAIT.
if (e.mov_after_func == FUNC_COMBO || e.mov_after_func == FUNC_ATTACK ||
e.mov_after_func == FUNC_MOB_SKILL) {
e.func = e.mov_after_func;
e.angle = e.mov_after_rot;
} else {
e.func = FUNC_WAIT;
}
e.mov_after_func = FUNC_WAIT;
} else {
e.x = e.sx + (e.tx - e.sx) * t;
e.y = e.sy + (e.ty - e.sy) * t;
+67 -4
View File
@@ -11,6 +11,7 @@
#include "wire.h"
#include <cstdint>
#include <deque>
#include <string>
#include <unordered_map>
#include <vector>
@@ -27,6 +28,29 @@ enum : uint8_t {
FUNC_SKILL = 0x80,
};
// CLIENT-GAP §3.2: one entry of a remote actor's TCP state queue, mirroring the
// reference CInstanceBase::PushTCPState / StateProcess model. `chk_time` is the
// server command time (TPacketGCMove::dwTime); the entry is released once the
// store's server-frame clock reaches it. `arg` carries the motion index for the
// FUNC_COMBO / FUNC_SKILL cases.
struct StateCmd {
uint32_t chk_time = 0;
float dst_x = 0, dst_y = 0;
float rot = 0;
uint8_t func = FUNC_WAIT;
uint8_t arg = 0;
uint32_t dur_ms = 0; // server-supplied walk duration; 0 = derive from distance/speed
};
// Staleness valve for the server-frame gate. The reference StateProcess has no
// such cap, but our server-frame clock is not yet validated against a live
// 40250 server (§3.2): if chk_time is stamped in a timebase we don't share, a
// naive "wait until frame >= chk_time" would freeze the remote actor forever.
// A chk_time more than this far ahead of the store's frame clock is treated as
// out-of-band and released immediately. Deliberate deviation, documented in
// CLIENT-GAP-FIX.md §3.2.
static constexpr uint32_t STATE_QUEUE_MAX_WAIT_MS = 1000;
struct Entity {
uint32_t vid = 0;
uint16_t race = 0;
@@ -51,13 +75,24 @@ struct Entity {
uint32_t move_start_ms = 0;
uint32_t move_dur_ms = 0;
// CLIENT-GAP §3.2: TCP state queue + the "do this once the walk reaches Dst"
// latch (reference m_kMovAfterFunc / m_dwMovAfterArg). `skip_collision` mirrors
// __EnableSkipCollision(): a walk kicked off by a network state packet ignores
// actor-vs-actor collision until it arrives.
std::deque<StateCmd> state_queue;
uint8_t mov_after_func = FUNC_WAIT;
uint8_t mov_after_arg = 0;
float mov_after_rot = 0;
bool skip_collision = false;
// combat / status (0 = unknown until the server sends it)
uint8_t attack_speed = 0; // GC_CHARACTER_ADD[2]/UPDATE bAttackSpeed (x100)
int32_t hp = 0, max_hp = 0;
int32_t sp = 0, max_sp = 0;
int32_t level = 0;
bool dead = false;
bool stunned = false;
bool stunned = false; // server AFFECT_STUN (InstanceBaseEffect.cpp:932)
bool knock_down = false; // knockback motion is playing (CActorInstance::IsKnockDown)
uint32_t mount_vnum = 0; // 0 = on foot (GC_MOUNT / GC_CHAR_ADD_INFO)
int32_t guild = 0;
int16_t alignment = 0;
@@ -262,6 +297,8 @@ struct ShopTab {
struct ExchangeSlot {
uint32_t vnum = 0;
uint8_t count = 0;
int32_t sockets[ITEM_SOCKET_SLOT_MAX_NUM] = {0, 0, 0};
ItemAttr attrs[ITEM_ATTRIBUTE_SLOT_MAX_NUM] = {};
};
struct ExchangeState {
bool active = false;
@@ -458,6 +495,16 @@ public:
};
void set_now(uint32_t now_ms) { m_now = now_ms; }
// CLIENT-GAP §3.2: the server-frame clock the TCP state queue releases against
// (reference ELTimer_GetServerFrameMSec). 0 = "not clock-aligned yet" — while
// it is 0 every queued command is released immediately (degrades to the old
// apply-on-arrival behaviour until the handshake lDelta clock is wired, §3.2/5).
void set_server_frame_ms(uint32_t ms) { m_server_frame_ms = ms; }
uint32_t server_frame_ms() const { return m_server_frame_ms; }
// Release every due entry of every entity's TCP state queue. Called from tick()
// and again right after mut_move() enqueues, so an already-due command applies
// synchronously.
void process_states();
// Feed one complete game-phase packet. `body` points at the packet start
// (header/length included); `len` == that length. Unknown headers ignored.
@@ -491,8 +538,12 @@ public:
void mut_despawn(uint32_t vid);
// angle_deg is the already-decoded compass heading (m2dev: rot*360/256;
// classic: rot*5 — the two wire encodings differ, so decode at the parser).
// CLIENT-GAP §3.2: enqueues a StateCmd on the entity's TCP state queue rather
// than mutating the position directly, then drains anything already due.
// `arg` is the motion index (FUNC_COMBO / FUNC_SKILL); `chk_time` is the
// server command time (0 = release immediately).
void mut_move(uint32_t vid, float angle_deg, uint8_t func, float tx, float ty,
uint32_t dur_ms);
uint32_t dur_ms, uint8_t arg = 0, uint32_t chk_time = 0);
// Full stat block (index = POINT_*). `n` <= 256.
void mut_set_points(const int32_t *pts, int n);
// One stat delta (GC_CHARACTER_POINT_CHANGE equivalent).
@@ -523,6 +574,9 @@ public:
void mut_change_speed(uint32_t vid, uint16_t moving_speed);
void mut_walk_mode(uint32_t vid, uint8_t mode);
void mut_stun(uint32_t vid);
// CLIENT-GAP §3.7 — knockback-motion latch (client-only, animation-bound).
// Set true when a knockdown .msa starts, false when it ends; never from a timer.
void mut_knockdown(uint32_t vid, bool on);
void mut_dead(uint32_t vid);
void mut_motion(uint32_t vid, uint32_t victim_vid, uint16_t motion);
void mut_target(uint32_t vid, uint8_t hp_pct);
@@ -613,7 +667,10 @@ public:
// or deny a pending friend request.
void mut_friend_invite(const std::string &name);
void mut_exchange_start(uint32_t partner_vid);
void mut_exchange_item(bool self, uint8_t slot, uint32_t vnum, uint8_t count);
// `sockets` and `attrs` mirror the 40250 GC_EXCHANGE item payload. They are
// optional for synthetic/server-command updates and default to zero.
void mut_exchange_item(bool self, uint8_t slot, uint32_t vnum, uint8_t count,
const int32_t *sockets = nullptr, const ItemAttr *attrs = nullptr);
void mut_exchange_item_del(bool self, uint8_t slot);
void mut_exchange_gold(bool self, int64_t gold);
void mut_exchange_accept(bool self, bool accepted);
@@ -1098,13 +1155,19 @@ public:
private:
Entity &touch(uint32_t vid, bool &created);
void start_move(Entity &e, float tx, float ty, uint32_t start_ms, uint32_t dur_ms, uint8_t func);
void mark_vitals(uint32_t vid);
Item *mut_slot(uint8_t window, uint16_t cell);
// CLIENT-GAP §3.2 helpers for the TCP state queue.
void drain_state_queue(Entity &e); // release due entries for one entity
void apply_state_cmd(Entity &e, const StateCmd &c); // the FUNC_* switch (1.0 / 50.0 thresholds)
void begin_state_walk(Entity &e, const StateCmd &c, uint8_t after_func); // Src/Dst + skip-collision
static bool can_process_network_state(const Entity &e); // ~ __CanProcessNetworkStatePacket
static bool is_enable_tcp_process(const Entity &e, uint8_t func); // ~ __IsEnableTCPProcess
std::unordered_map<uint32_t, Entity> m_ents;
uint32_t m_main_vid = 0;
uint32_t m_now = 0;
uint32_t m_server_frame_ms = 0;
std::vector<Change> m_changes;
std::vector<uint32_t> m_dirty;
std::string m_bgm_name;
+370 -171
View File
@@ -5,6 +5,7 @@
#include "classic/classic_session.h"
#include "game_client.h"
#include "mark_client.h"
#include "net_bounds.h"
#include <godot_cpp/classes/engine.hpp>
#include <godot_cpp/classes/os.hpp>
@@ -26,6 +27,27 @@ using namespace godot;
namespace mtgodot {
namespace {
bool valid_angle(double degrees) {
return std::isfinite(degrees);
}
bool valid_item_pos(int window, int cell) {
return mtnet::bounds::u8(window) && mtnet::bounds::u16(cell);
}
bool read_int(const Dictionary &dict, const char *key, int64_t &value, int64_t fallback) {
const Variant raw = dict.get(key, fallback);
if (raw.get_type() != Variant::INT) {
return false;
}
value = static_cast<int64_t>(raw);
return true;
}
} // namespace
M2Client::M2Client() {
set_process(true);
}
@@ -56,6 +78,7 @@ void M2Client::_bind_methods() {
ClassDB::bind_method(D_METHOD("attack", "motion", "victim_vid"), &M2Client::attack);
ClassDB::bind_method(D_METHOD("set_target", "victim_vid"), &M2Client::set_target);
ClassDB::bind_method(D_METHOD("say", "type", "text"), &M2Client::say);
ClassDB::bind_method(D_METHOD("send_emoticon", "index"), &M2Client::send_emoticon);
ClassDB::bind_method(D_METHOD("whisper", "to", "text"), &M2Client::whisper);
ClassDB::bind_method(D_METHOD("cast_skill", "motion_idx", "rot_deg", "x", "y"), &M2Client::cast_skill);
ClassDB::bind_method(D_METHOD("use_skill", "skill_id", "target_vid"), &M2Client::use_skill);
@@ -134,6 +157,7 @@ void M2Client::_bind_methods() {
PropertyInfo(Variant::DICTIONARY, "entity")));
ADD_SIGNAL(MethodInfo("chat", PropertyInfo(Variant::INT, "type"),
PropertyInfo(Variant::INT, "vid"), PropertyInfo(Variant::STRING, "text")));
ADD_SIGNAL(MethodInfo("emoticon_requested", PropertyInfo(Variant::INT, "index")));
ADD_SIGNAL(MethodInfo("whisper_received", PropertyInfo(Variant::INT, "sub"),
PropertyInfo(Variant::STRING, "from"), PropertyInfo(Variant::STRING, "text")));
// combat / status
@@ -449,6 +473,12 @@ void M2Client::net_poll() {
void M2Client::connect_to_server(const String &auth_host, int auth_port, const String &g_host,
int g_port, const String &id, const String &pw) {
disconnect_from_server();
if (auth_host.is_empty() || g_host.is_empty() || auth_port <= 0 || g_port <= 0 ||
!mtnet::bounds::u16(auth_port) || !mtnet::bounds::u16(g_port)) {
emit_signal("login_failed", String("invalid server address"));
set_stage(Stage::Failed);
return;
}
game_host = g_host;
game_port = g_port;
account_id = id;
@@ -466,6 +496,19 @@ void M2Client::connect_to_server(const String &auth_host, int auth_port, const S
// then connect to game_host with the returned login ticket.
if (OS::get_singleton()->get_environment("MT_PROTOCOL") == "classic") {
classic_sess = std::make_unique<mtnet::classic::ClassicSession>();
// §1.5 (W1 G1 wiring): CG_CLIENT_VERSION2 reports the running binary's
// filename; feed it the real executable name rather than the built-in
// default so the server-side version gate sees what ClientVS22 sends.
classic_sess->set_executable_name(
std::string(OS::get_singleton()->get_executable_path().get_file().utf8().get_data()));
// §2.2 (W1 G1 wiring): non-PC spawn names come from root/npclist.txt.
// M2Client has no asset-root handle, so the parser falls back to
// $MT_ASSETS/$M2_ASSETS (see npc_names.h default_npclist_path()); an
// explicit set_npclist_path() awaits asset-root plumbing into M2Client.
if (const String assets = OS::get_singleton()->get_environment("MT_ASSETS"); !assets.is_empty()) {
classic_sess->parser().set_npclist_path(
std::string(assets.utf8().get_data()) + "/root/npclist.txt");
}
// The 40250 server expects CG_ENTERGAME shortly after the loading phase
// starts. Keep the live client in the same timing window as m2dev while
// allowing a deployment-specific override for unusually large bursts.
@@ -570,14 +613,23 @@ bool M2Client::enter_game(int index) {
bool M2Client::create_character(int slot, const godot::String &name, int job, int shape,
int con, int intel, int str, int dex) {
const std::string name_wire = std::string(name.utf8().get_data());
const size_t name_limit = classic_sess ? mtnet::classic::CHARACTER_NAME_MAX_LEN :
mtnet::CHARACTER_NAME_MAX_LEN;
if (slot < 0 || slot >= mtnet::PLAYER_PER_ACCOUNT4 || name_wire.empty() ||
name_wire.size() > name_limit || !mtnet::bounds::u16(job) || !mtnet::bounds::u8(shape) ||
!mtnet::bounds::u8(con) || !mtnet::bounds::u8(intel) || !mtnet::bounds::u8(str) ||
!mtnet::bounds::u8(dex)) {
return false;
}
if (classic_sess) {
return classic_sess->create_character(slot, std::string(name.utf8().get_data()), job, shape,
return classic_sess->create_character(slot, name_wire, job, shape,
con, intel, str, dex);
}
if (!game) {
return false;
}
return game->create_character(slot, std::string(name.utf8().get_data()), job, shape,
return game->create_character(slot, name_wire, job, shape,
con, intel, str, dex);
}
@@ -682,7 +734,8 @@ godot::Array M2Client::build_char_list() const {
// --- in-game intents ---
bool M2Client::move(int func, int arg, double rot_deg, int x, int y) {
if (!is_in_game()) {
if (!is_in_game() || !mtnet::bounds::u8(func) || !mtnet::bounds::u8(arg) ||
!valid_angle(rot_deg) || !mtnet::bounds::i32(x) || !mtnet::bounds::i32(y)) {
return false;
}
double r = std::fmod(rot_deg, 360.0);
@@ -719,7 +772,7 @@ bool M2Client::request_warp() {
}
bool M2Client::fishing(double rot_deg) {
if (!is_in_game()) {
if (!is_in_game() || !valid_angle(rot_deg)) {
return false;
}
double r = std::fmod(rot_deg, 360.0);
@@ -757,11 +810,17 @@ bool M2Client::sync_positions(const Array &positions) {
if (!d.has("vid") || !d.has("x") || !d.has("y")) {
return false;
}
const int64_t vid = (int64_t)d["vid"];
const int64_t x = (int64_t)d["x"];
const int64_t y = (int64_t)d["y"];
if (vid < 0 || vid > 0xffffffffLL || x < INT32_MIN || x > INT32_MAX ||
y < INT32_MIN || y > INT32_MAX) {
const Variant raw_vid = d["vid"];
const Variant raw_x = d["x"];
const Variant raw_y = d["y"];
if (raw_vid.get_type() != Variant::INT || raw_x.get_type() != Variant::INT ||
raw_y.get_type() != Variant::INT) {
return false;
}
const int64_t vid = (int64_t)raw_vid;
const int64_t x = (int64_t)raw_x;
const int64_t y = (int64_t)raw_y;
if (!mtnet::bounds::u32(vid) || !mtnet::bounds::i32(x) || !mtnet::bounds::i32(y)) {
return false;
}
if (classic_sess) classic_elements.push_back({(uint32_t)vid, (int32_t)x, (int32_t)y});
@@ -773,7 +832,8 @@ bool M2Client::sync_positions(const Array &positions) {
}
bool M2Client::attack(int motion, int victim_vid) {
if (!is_in_game()) {
if (!is_in_game() || !mtnet::bounds::u8(motion) || victim_vid <= 0 ||
!mtnet::bounds::u32(victim_vid)) {
return false;
}
if (classic_sess) {
@@ -783,7 +843,8 @@ bool M2Client::attack(int motion, int victim_vid) {
}
bool M2Client::set_target(int victim_vid) {
if (!is_in_game()) {
// VID 0 is the stock client's explicit "clear target" packet.
if (!is_in_game() || victim_vid < 0 || !mtnet::bounds::u32(victim_vid)) {
return false;
}
if (classic_sess) {
@@ -793,34 +854,65 @@ bool M2Client::set_target(int victim_vid) {
}
bool M2Client::say(int type, const String &text) {
if (!is_in_game()) {
if (!is_in_game() || !mtnet::bounds::u8(type)) {
return false;
}
const std::string text_wire = std::string(text.utf8().get_data());
if ((classic_sess && text_wire.size() >= 512) ||
(!classic_sess && text_wire.size() > 0xffffu - sizeof(mtnet::CGChat) - 1)) {
return false;
}
if (classic_sess) {
return classic_sess->send_chat((uint8_t)type, std::string(text.utf8().get_data()));
return classic_sess->send_chat((uint8_t)type, text_wire);
}
return game && game->send_chat((uint8_t)type, std::string(text.utf8().get_data()));
return game && game->send_chat((uint8_t)type, text_wire);
}
bool M2Client::send_emoticon(int index) {
// Registration order from playersettingmodule.py. The reference client's
// SendEmoticon sends this token through SendChatPacket, while the received
// token is parsed back into the matching visual effect by the chat handler.
static constexpr const char *kTokens[] = {
"(sweat)", "(money)", "(happy)", "(like)", "(love)", "(angry)",
"(aha)", "(sad)", "(sorry)", "(!)", "(?)", "(fish)"
};
constexpr int kTokenCount = sizeof(kTokens) / sizeof(kTokens[0]);
if (index < 0 || index >= kTokenCount || !say(0 /* CHAT_TYPE_TALKING */, String(kTokens[index]))) {
return false;
}
emit_signal("emoticon_requested", index);
return true;
}
bool M2Client::whisper(const String &to, const String &text) {
const std::string to_wire = std::string(to.utf8().get_data());
const std::string text_wire = std::string(text.utf8().get_data());
if (classic_sess) {
return is_in_game() && classic_sess->send_whisper(std::string(to.utf8().get_data()),
std::string(text.utf8().get_data()));
return is_in_game() && !to_wire.empty() &&
to_wire.size() <= mtnet::classic::CHARACTER_NAME_MAX_LEN && text_wire.size() < 512 &&
classic_sess->send_whisper(to_wire, text_wire);
}
if (!game || !is_in_game()) {
if (!game || !is_in_game() || to_wire.empty() ||
to_wire.size() > mtnet::CHARACTER_NAME_MAX_LEN ||
text_wire.size() > 0xffffu - sizeof(mtnet::CGWhisper) - 1) {
return false;
}
return game->send_whisper(std::string(to.utf8().get_data()),
std::string(text.utf8().get_data()));
return game->send_whisper(to_wire, text_wire);
}
bool M2Client::cast_skill(int motion_idx, double rot_deg, int x, int y) {
// FUNC_SKILL | (motion_idx & 0x7F); reuse move()'s rot/time handling.
return move(0x80 | (motion_idx & 0x7F), 0, rot_deg, x, y);
// FUNC_SKILL | motion_idx; do not silently mask an invalid caller value.
if (motion_idx < 0 || motion_idx > 0x7f) {
return false;
}
return move(0x80 | motion_idx, 0, rot_deg, x, y);
}
bool M2Client::use_skill(int skill_id, int target_vid) {
if (!is_in_game() || skill_id <= 0) {
// §3.8 mod 3: signed params, range-checked before the uint32 wire cast.
// skill_id is a real skill index (0 < id < SKILL_MAX_NUM), not a free u32.
if (!is_in_game() || !mtnet::bounds::skill_index(skill_id) || target_vid < 0 ||
!mtnet::bounds::u32(target_vid)) {
return false;
}
if (classic_sess) {
@@ -857,7 +949,8 @@ bool M2Client::shoot(int skill_id) {
}
bool M2Client::add_fly_targeting(int target_vid, int x, int y) {
if (!is_in_game() || target_vid < 0) {
if (!is_in_game() || target_vid < 0 || !mtnet::bounds::u32(target_vid) ||
!mtnet::bounds::i32(x) || !mtnet::bounds::i32(y)) {
return false;
}
if (classic_sess) return classic_sess->send_add_fly_targeting((uint32_t)target_vid, (int32_t)x, (int32_t)y);
@@ -866,6 +959,10 @@ bool M2Client::add_fly_targeting(int target_vid, int x, int y) {
}
bool M2Client::skill_up(int skill_id) {
// §3.9 / §3.10: /skillup 只发正的、低于 SKILL_MAX_NUM 的技能索引(与 use_skill 同一谓词)。
if (!is_in_game() || !mtnet::bounds::skill_index(skill_id)) {
return false;
}
return say(0 /*CHAT_TYPE_TALKING*/, String("/skillup ") + String::num_int64(skill_id));
}
@@ -906,7 +1003,7 @@ Array M2Client::get_quickslots() const {
}
Dictionary d;
d["pos"] = pos;
d["type"] = (int)qs.type; // 1 item / 2 skill / 3 command / 4 emotion
d["type"] = (int)qs.type; // 1 inventory / 2 skill / 3 emotion / 4 shop (not executable)
d["ref"] = (int)qs.position; // item cell / skill id / ...
out.push_back(d);
}
@@ -914,8 +1011,8 @@ Array M2Client::get_quickslots() const {
}
bool M2Client::quickslot_add(int pos, int type, int ref) {
if (!is_in_game() || pos < 0 || pos >= mtnet::QUICKSLOT_MAX_NUM ||
type < 1 || type > 4 || ref < 0 || ref > 255) {
if (!is_in_game() || !mtnet::bounds::quick_slot(pos) || !mtnet::bounds::u8(type) ||
type < 1 || type > 3 || !mtnet::bounds::u8(ref)) {
return false;
}
if (classic_sess) return classic_sess->send_quickslot_add((uint8_t)pos, (uint8_t)type, (uint8_t)ref);
@@ -924,7 +1021,7 @@ bool M2Client::quickslot_add(int pos, int type, int ref) {
}
bool M2Client::quickslot_del(int pos) {
if (!is_in_game() || pos < 0 || pos >= mtnet::QUICKSLOT_MAX_NUM) {
if (!is_in_game() || !mtnet::bounds::quick_slot(pos)) {
return false;
}
if (classic_sess) return classic_sess->send_quickslot_del((uint8_t)pos);
@@ -933,8 +1030,8 @@ bool M2Client::quickslot_del(int pos) {
}
bool M2Client::quickslot_swap(int pos, int change_pos) {
if (!is_in_game() || pos < 0 || pos >= mtnet::QUICKSLOT_MAX_NUM ||
change_pos < 0 || change_pos >= mtnet::QUICKSLOT_MAX_NUM) {
if (!is_in_game() || !mtnet::bounds::quick_slot(pos) ||
!mtnet::bounds::quick_slot(change_pos)) {
return false;
}
if (classic_sess) return classic_sess->send_quickslot_swap((uint8_t)pos, (uint8_t)change_pos);
@@ -943,7 +1040,7 @@ bool M2Client::quickslot_swap(int pos, int change_pos) {
}
bool M2Client::click_npc(int vid) {
if (!is_in_game()) {
if (!is_in_game() || vid <= 0 || !mtnet::bounds::u32(vid)) {
return false;
}
if (classic_sess) {
@@ -952,7 +1049,7 @@ bool M2Client::click_npc(int vid) {
return game && game->send_on_click((uint32_t)vid);
}
bool M2Client::script_answer(int answer) {
if (!is_in_game()) {
if (!is_in_game() || !mtnet::bounds::u8(answer)) {
return false;
}
if (classic_sess) {
@@ -961,11 +1058,14 @@ bool M2Client::script_answer(int answer) {
return game && game->send_script_answer((uint8_t)answer);
}
bool M2Client::script_button(int idx) {
if (classic_sess) return is_in_game() && idx >= 0 && classic_sess->send_script_button((uint32_t)idx);
return game && is_in_game() && game->send_script_button((uint32_t)idx);
if (!is_in_game() || idx < 0 || !mtnet::bounds::u32(idx)) {
return false;
}
if (classic_sess) return classic_sess->send_script_button((uint32_t)idx);
return game && game->send_script_button((uint32_t)idx);
}
bool M2Client::script_select_item(int selection) {
if (!is_in_game() || selection < 0) {
if (!is_in_game() || selection < 0 || !mtnet::bounds::u32(selection)) {
return false;
}
if (classic_sess) return classic_sess->send_script_select_item((uint32_t)selection);
@@ -977,9 +1077,9 @@ bool M2Client::quest_input(const String &text) {
return game && is_in_game() && game->send_quest_input(std::string(text.utf8().get_data()));
}
bool M2Client::quest_confirm(bool yes, int request_pid) {
if (classic_sess) return is_in_game() && request_pid >= 0 &&
if (classic_sess) return is_in_game() && request_pid >= 0 && mtnet::bounds::u32(request_pid) &&
classic_sess->send_quest_confirm(yes, (uint32_t)request_pid);
return game && is_in_game() && request_pid >= 0 &&
return game && is_in_game() && request_pid >= 0 && mtnet::bounds::u32(request_pid) &&
game->send_quest_confirm(yes, (uint32_t)request_pid);
}
bool M2Client::quest_cancel() {
@@ -1015,20 +1115,26 @@ Array M2Client::get_quests() const {
// --- P8 social / shop / storage ---
bool M2Client::party_invite(int vid) {
if (classic_sess) return is_in_game() && vid > 0 && classic_sess->send_party_invite((uint32_t)vid);
return game && is_in_game() && game->send_party_invite((uint32_t)vid);
if (classic_sess) return is_in_game() && vid > 0 && mtnet::bounds::u32(vid) &&
classic_sess->send_party_invite((uint32_t)vid);
return game && is_in_game() && vid > 0 && mtnet::bounds::u32(vid) &&
game->send_party_invite((uint32_t)vid);
}
bool M2Client::party_answer(int leader_pid, bool accept) {
if (classic_sess) return is_in_game() && leader_pid > 0 &&
if (classic_sess) return is_in_game() && leader_pid > 0 && mtnet::bounds::u32(leader_pid) &&
classic_sess->send_party_answer((uint32_t)leader_pid, accept);
return game && is_in_game() && game->send_party_invite_answer((uint32_t)leader_pid, accept);
return game && is_in_game() && leader_pid > 0 && mtnet::bounds::u32(leader_pid) &&
game->send_party_invite_answer((uint32_t)leader_pid, accept);
}
bool M2Client::party_leave(int pid) {
if (classic_sess) return is_in_game() && pid > 0 && classic_sess->send_party_remove((uint32_t)pid);
return game && is_in_game() && pid >= 0 && game->send_party_remove((uint32_t)pid);
if (classic_sess) return is_in_game() && pid > 0 && mtnet::bounds::u32(pid) &&
classic_sess->send_party_remove((uint32_t)pid);
return game && is_in_game() && pid > 0 && mtnet::bounds::u32(pid) &&
game->send_party_remove((uint32_t)pid);
}
bool M2Client::party_use_skill(int skill_index, int target_vid) {
if (!is_in_game() || skill_index < 0 || skill_index > 255 || target_vid < 0) {
if (!is_in_game() || !mtnet::bounds::u8(skill_index) || target_vid < 0 ||
!mtnet::bounds::u32(target_vid)) {
return false;
}
if (classic_sess) return classic_sess->send_party_use_skill((uint8_t)skill_index, (uint32_t)target_vid);
@@ -1036,12 +1142,15 @@ bool M2Client::party_use_skill(int skill_index, int target_vid) {
return game->send_party_use_skill((uint8_t)skill_index, (uint32_t)target_vid);
}
bool M2Client::party_set_distribute(int mode) {
if (classic_sess) return is_in_game() && mode >= 0 && mode <= 255 &&
if (classic_sess) return is_in_game() && mtnet::bounds::u8(mode) &&
classic_sess->send_party_parameter((uint8_t)mode);
return game && is_in_game() && game->send_party_parameter((uint8_t)mode);
return game && is_in_game() && mtnet::bounds::u8(mode) &&
game->send_party_parameter((uint8_t)mode);
}
bool M2Client::party_set_state(int pid, int role, bool on) {
if (!is_in_game() || pid <= 0 || role < 0 || role > 255) return false;
if (!is_in_game() || pid <= 0 || !mtnet::bounds::u32(pid) || role < 0 || role >= 8) {
return false;
}
if (classic_sess) return classic_sess->send_party_set_state((uint32_t)pid, (uint8_t)role, on);
return game && game->send_party_set_state((uint32_t)pid, (uint8_t)role, on ? 1 : 0);
}
@@ -1130,15 +1239,18 @@ Dictionary M2Client::get_lover() const {
}
bool M2Client::shop_buy(int pos, int count) {
if (classic_sess) return is_in_game() && pos >= 0 && pos <= 255 &&
classic_sess->send_shop_buy((uint8_t)pos, (uint8_t)(count < 1 ? 1 : count));
return game && is_in_game() && game->send_shop_buy((uint8_t)pos, (uint8_t)(count < 1 ? 1 : count));
if (!is_in_game() || !mtnet::bounds::u8(pos) || count < 1 || !mtnet::bounds::u8(count)) {
return false;
}
if (classic_sess) return classic_sess->send_shop_buy((uint8_t)pos, (uint8_t)count);
return game && game->send_shop_buy((uint8_t)pos, (uint8_t)count);
}
bool M2Client::shop_sell(int inv_cell, int count) {
if (classic_sess) return is_in_game() && inv_cell >= 0 && inv_cell <= 255 &&
classic_sess->send_shop_sell((uint8_t)inv_cell, (uint8_t)(count < 1 ? 1 : count));
return game && is_in_game() &&
game->send_shop_sell((uint8_t)inv_cell, (uint8_t)(count < 1 ? 1 : count));
if (!is_in_game() || !mtnet::bounds::u8(inv_cell) || count < 0 || !mtnet::bounds::u8(count)) {
return false;
}
if (classic_sess) return classic_sess->send_shop_sell((uint8_t)inv_cell, (uint8_t)count);
return game->send_shop_sell((uint8_t)inv_cell, (uint8_t)count);
}
bool M2Client::shop_close() {
if (classic_sess) return is_in_game() && classic_sess->send_shop_close();
@@ -1206,27 +1318,34 @@ Dictionary M2Client::get_shop() const {
}
bool M2Client::exchange_start(int vid) {
if (classic_sess) {
return is_in_game() && classic_sess->send_exchange_start((uint32_t)vid);
}
return game && is_in_game() && game->send_exchange_start((uint32_t)vid);
}
bool M2Client::exchange_add_item(int inv_window, int inv_cell, int display_pos) {
if (classic_sess) {
return is_in_game() && classic_sess->send_exchange_item_add((uint8_t)inv_window,
(uint16_t)inv_cell, (uint8_t)display_pos);
}
return game && is_in_game() &&
game->send_exchange_item_add((uint8_t)inv_window, (uint16_t)inv_cell, (uint8_t)display_pos);
}
bool M2Client::exchange_add_gold(int gold) {
if (gold < 0) {
if (!is_in_game() || !mtnet::bounds::u32(vid) || vid <= 0) {
return false;
}
if (classic_sess) {
return is_in_game() && classic_sess->send_exchange_gold((uint32_t)gold);
return classic_sess->send_exchange_start((uint32_t)vid);
}
return game && is_in_game() && game->send_exchange_gold((uint32_t)gold);
return game && game->send_exchange_start((uint32_t)vid);
}
bool M2Client::exchange_add_item(int inv_window, int inv_cell, int display_pos) {
if (!is_in_game() || !valid_item_pos(inv_window, inv_cell) ||
!mtnet::bounds::exchange_slot(display_pos)) {
return false;
}
if (classic_sess) {
return classic_sess->send_exchange_item_add((uint8_t)inv_window, (uint16_t)inv_cell,
(uint8_t)display_pos);
}
return game && game->send_exchange_item_add((uint8_t)inv_window, (uint16_t)inv_cell,
(uint8_t)display_pos);
}
bool M2Client::exchange_add_gold(int gold) {
if (!is_in_game() || !mtnet::bounds::u32(gold)) {
return false;
}
if (classic_sess) {
return classic_sess->send_exchange_gold((uint32_t)gold);
}
return game && game->send_exchange_gold((uint32_t)gold);
}
bool M2Client::exchange_accept() {
if (classic_sess) {
@@ -1260,10 +1379,26 @@ Dictionary M2Client::get_exchange() const {
continue;
}
Dictionary s;
s["slot"] = i;
s["vnum"] = (int)arr[i].vnum;
s["count"] = (int)arr[i].count;
a.push_back(s);
s["slot"] = i;
s["vnum"] = (int)arr[i].vnum;
s["count"] = (int)arr[i].count;
Array sockets;
for (int j = 0; j < mtnet::ITEM_SOCKET_SLOT_MAX_NUM; ++j) {
sockets.push_back(arr[i].sockets[j]);
}
s["sockets"] = sockets;
Array attrs;
for (int j = 0; j < mtnet::ITEM_ATTRIBUTE_SLOT_MAX_NUM; ++j) {
if (arr[i].attrs[j].type == 0) {
continue;
}
Dictionary a;
a["type"] = (int)arr[i].attrs[j].type;
a["value"] = (int)arr[i].attrs[j].value;
attrs.push_back(a);
}
s["attrs"] = attrs;
a.push_back(s);
}
return a;
};
@@ -1273,28 +1408,37 @@ Dictionary M2Client::get_exchange() const {
}
bool M2Client::safebox_checkin(int safe_pos, int inv_window, int inv_cell) {
if (classic_sess) {
return is_in_game() && classic_sess->send_safebox_checkin((uint8_t)safe_pos,
(uint8_t)inv_window, (uint16_t)inv_cell);
if (!is_in_game() || !mtnet::bounds::u8(safe_pos) || !valid_item_pos(inv_window, inv_cell)) {
return false;
}
return game && is_in_game() &&
game->send_safebox_checkin((uint8_t)safe_pos, (uint8_t)inv_window, (uint16_t)inv_cell);
if (classic_sess) {
return classic_sess->send_safebox_checkin((uint8_t)safe_pos, (uint8_t)inv_window,
(uint16_t)inv_cell);
}
return game && game->send_safebox_checkin((uint8_t)safe_pos, (uint8_t)inv_window,
(uint16_t)inv_cell);
}
bool M2Client::safebox_checkout(int safe_pos, int inv_window, int inv_cell) {
if (classic_sess) {
return is_in_game() && classic_sess->send_safebox_checkout((uint8_t)safe_pos,
(uint8_t)inv_window, (uint16_t)inv_cell);
if (!is_in_game() || !mtnet::bounds::u8(safe_pos) || !valid_item_pos(inv_window, inv_cell)) {
return false;
}
return game && is_in_game() &&
game->send_safebox_checkout((uint8_t)safe_pos, (uint8_t)inv_window, (uint16_t)inv_cell);
if (classic_sess) {
return classic_sess->send_safebox_checkout((uint8_t)safe_pos, (uint8_t)inv_window,
(uint16_t)inv_cell);
}
return game && game->send_safebox_checkout((uint8_t)safe_pos, (uint8_t)inv_window,
(uint16_t)inv_cell);
}
bool M2Client::safebox_move(int from_cell, int to_cell, int count) {
if (classic_sess) {
return is_in_game() && classic_sess->send_safebox_move((uint16_t)from_cell,
(uint16_t)to_cell, (uint8_t)(count < 1 ? 1 : count));
if (!is_in_game() || !mtnet::bounds::u16(from_cell) || !mtnet::bounds::u16(to_cell) ||
!mtnet::bounds::u8(count)) {
return false;
}
return game && is_in_game() &&
game->send_safebox_move((uint16_t)from_cell, (uint16_t)to_cell, (uint8_t)(count < 1 ? 1 : count));
if (classic_sess) {
return classic_sess->send_safebox_move((uint16_t)from_cell, (uint16_t)to_cell,
(uint8_t)count);
}
return game && game->send_safebox_move((uint16_t)from_cell, (uint16_t)to_cell, (uint8_t)count);
}
bool M2Client::is_safebox_open() const {
const mtnet::EntityStore *world = active_world();
@@ -1358,9 +1502,12 @@ Array M2Client::get_mall_items() const {
return out;
}
bool M2Client::mall_checkout(int mall_pos, int inv_window, int inv_cell) {
if (!is_in_game() || !mtnet::bounds::u8(mall_pos) || !valid_item_pos(inv_window, inv_cell)) {
return false;
}
if (classic_sess) {
return is_in_game() && classic_sess->send_mall_checkout((uint8_t)mall_pos,
(uint8_t)inv_window, (uint16_t)inv_cell);
return classic_sess->send_mall_checkout((uint8_t)mall_pos, (uint8_t)inv_window,
(uint16_t)inv_cell);
}
return game && game->send_mall_checkout((uint8_t)mall_pos, (uint8_t)inv_window,
(uint16_t)inv_cell);
@@ -1383,38 +1530,76 @@ bool M2Client::mall_password(const String &password) {
// --- private (PC) shop ---
bool M2Client::open_private_shop(const godot::String &sign, const godot::Array &items) {
if (!game && !classic_sess) {
if (!is_in_game() || (!game && !classic_sess) ||
items.size() > mtnet::bounds::PRIVATE_SHOP_ITEM_COUNT) {
return false;
}
const std::string sign_wire = std::string(sign.utf8().get_data());
if (sign_wire.size() > mtnet::SHOP_SIGN_MAX_LEN) {
return false;
}
struct PrivateShopItemInput {
uint32_t vnum;
uint8_t count;
uint8_t inv_window;
uint16_t inv_cell;
uint32_t price;
uint8_t display_pos;
};
std::vector<PrivateShopItemInput> parsed;
parsed.reserve(items.size());
for (int i = 0; i < items.size(); ++i) {
const Variant raw = items[i];
if (raw.get_type() != Variant::DICTIONARY) {
return false;
}
const Dictionary d = raw;
int64_t vnum = 0;
int64_t count = 1;
int64_t inv_window = mtnet::WINDOW_INVENTORY;
int64_t inv_cell = 0;
int64_t price = 0;
int64_t display_pos = i;
if (!read_int(d, "vnum", vnum, 0) || !read_int(d, "count", count, 1) ||
!read_int(d, "inv_window", inv_window, mtnet::WINDOW_INVENTORY) ||
!read_int(d, "inv_cell", inv_cell, 0) || !read_int(d, "price", price, 0) ||
!read_int(d, "display_pos", display_pos, i) || !mtnet::bounds::u32(vnum) ||
!mtnet::bounds::u8(count) || count < 1 || !mtnet::bounds::u8(inv_window) ||
!mtnet::bounds::u16(inv_cell) || !mtnet::bounds::u32(price) ||
display_pos < 0 || !mtnet::bounds::shop_slot(display_pos)) {
return false;
}
parsed.push_back({(uint32_t)vnum, (uint8_t)count, (uint8_t)inv_window,
(uint16_t)inv_cell, (uint32_t)price, (uint8_t)display_pos});
}
if (classic_sess) {
std::vector<mtnet::classic::CGMyShopItem> v;
v.reserve(items.size());
for (int i = 0; i < items.size() && i < 39; ++i) {
Dictionary d = items[i];
v.reserve(parsed.size());
for (const auto &in : parsed) {
mtnet::classic::CGMyShopItem e{};
e.vnum = (uint32_t)(int64_t)d.get("vnum", 0);
e.count = (uint8_t)(int)d.get("count", 1);
e.pos = {(uint8_t)(int)d.get("inv_window", mtnet::WINDOW_INVENTORY),
(uint16_t)(int)d.get("inv_cell", 0)};
e.price = (uint32_t)(int64_t)d.get("price", 0);
e.display_pos = (uint8_t)(int)d.get("display_pos", i);
e.vnum = in.vnum;
e.count = in.count;
e.pos = {in.inv_window, in.inv_cell};
e.price = in.price;
e.display_pos = in.display_pos;
v.push_back(e);
}
return classic_sess->send_private_shop(std::string(sign.utf8().get_data()), v);
return classic_sess->send_private_shop(sign_wire, v);
}
std::vector<mtnet::MyShopItem> v;
for (int i = 0; i < items.size(); ++i) {
Dictionary d = items[i];
v.reserve(parsed.size());
for (const auto &in : parsed) {
mtnet::MyShopItem e{};
e.vnum = (uint32_t)(int64_t)d.get("vnum", 0);
e.count = (uint8_t)(int)d.get("count", 1);
e.pos = {(uint8_t)(int)d.get("inv_window", mtnet::WINDOW_INVENTORY),
(uint16_t)(int)d.get("inv_cell", 0)};
e.price = (uint32_t)(int64_t)d.get("price", 0);
e.display_pos = (uint8_t)(int)d.get("display_pos", i);
e.vnum = in.vnum;
e.count = in.count;
e.pos = {in.inv_window, in.inv_cell};
e.price = in.price;
e.display_pos = in.display_pos;
v.push_back(e);
}
return game->send_open_private_shop(std::string(sign.utf8().get_data()), v);
return game->send_open_private_shop(sign_wire, v);
}
bool M2Client::close_private_shop() {
if (classic_sess) {
@@ -1474,15 +1659,25 @@ Dictionary M2Client::get_cube() const {
return d;
}
bool M2Client::cube_make(int result_index) {
if (classic_sess) return is_in_game() && classic_sess->send_cube_make(result_index);
if (!is_in_game() || result_index < 0 || !mtnet::bounds::u32(result_index)) {
return false;
}
if (classic_sess) return classic_sess->send_cube_make(result_index);
return game && game->send_cube_make(result_index);
}
bool M2Client::cube_request_result_list(int npc_vnum) {
if (classic_sess) return is_in_game() && classic_sess->send_cube_result_list(npc_vnum);
if (!is_in_game() || !mtnet::bounds::u32(npc_vnum)) {
return false;
}
if (classic_sess) return classic_sess->send_cube_result_list(npc_vnum);
return game && game->send_cube_result_list((uint32_t)npc_vnum);
}
bool M2Client::cube_request_materials(int start_index, int count) {
if (classic_sess) return is_in_game() && classic_sess->send_cube_materials(start_index, count);
if (!is_in_game() || !mtnet::bounds::u8(start_index) || count < 1 ||
!mtnet::bounds::u8(count)) {
return false;
}
if (classic_sess) return classic_sess->send_cube_materials(start_index, count);
return game && game->send_cube_material_info(start_index, count);
}
bool M2Client::cube_open() {
@@ -1498,20 +1693,19 @@ bool M2Client::cube_list() {
return game && is_in_game() && game->send_chat(mtnet::CHAT_TYPE_COMMAND, "/cube list");
}
bool M2Client::cube_add_item(int cube_index, int inventory_index) {
if (classic_sess) {
return is_in_game() && classic_sess->send_cube_add_item(cube_index, inventory_index);
}
if (!game || !is_in_game() || cube_index < 0 || cube_index >= 24 ||
inventory_index < 0 || inventory_index > 255) {
if (!is_in_game() || !mtnet::bounds::cube_slot(cube_index) ||
!mtnet::bounds::u8(inventory_index)) {
return false;
}
if (classic_sess) return classic_sess->send_cube_add_item(cube_index, inventory_index);
if (!game) return false;
return game->send_chat(mtnet::CHAT_TYPE_COMMAND, "/cube add " + std::to_string(cube_index) +
" " + std::to_string(inventory_index));
}
bool M2Client::cube_delete_item(int cube_index) {
if (classic_sess) return is_in_game() && classic_sess->send_cube_delete_item(cube_index);
return game && is_in_game() && cube_index >= 0 && cube_index < 24 &&
game->send_chat(mtnet::CHAT_TYPE_COMMAND, "/cube delete " + std::to_string(cube_index));
if (!is_in_game() || !mtnet::bounds::cube_slot(cube_index)) return false;
if (classic_sess) return classic_sess->send_cube_delete_item(cube_index);
return game && game->send_chat(mtnet::CHAT_TYPE_COMMAND, "/cube delete " + std::to_string(cube_index));
}
// --- guild / refine ---
@@ -1571,23 +1765,25 @@ Array M2Client::get_guild_grades() const {
return out;
}
bool M2Client::guild_add_member(int vid) {
if (classic_sess) return is_in_game() && vid > 0 &&
if (classic_sess) return is_in_game() && vid > 0 && mtnet::bounds::u32(vid) &&
classic_sess->send_guild_u32(mtnet::classic::GUILD_CG_ADD_MEMBER, (uint32_t)vid);
return game && is_in_game() && game->send_guild_sub_u32(mtnet::GUILD_CG_ADD_MEMBER, (uint32_t)vid);
return game && is_in_game() && vid > 0 && mtnet::bounds::u32(vid) &&
game->send_guild_sub_u32(mtnet::GUILD_CG_ADD_MEMBER, (uint32_t)vid);
}
bool M2Client::guild_remove_member(int pid) {
if (classic_sess) return is_in_game() && pid > 0 &&
if (classic_sess) return is_in_game() && pid > 0 && mtnet::bounds::u32(pid) &&
classic_sess->send_guild_u32(mtnet::classic::GUILD_CG_REMOVE_MEMBER, (uint32_t)pid);
return game && is_in_game() &&
return game && is_in_game() && pid > 0 && mtnet::bounds::u32(pid) &&
game->send_guild_sub_u32(mtnet::GUILD_CG_REMOVE_MEMBER, (uint32_t)pid);
}
bool M2Client::guild_offer(int amount) {
if (classic_sess) return is_in_game() && amount >= 0 &&
if (classic_sess) return is_in_game() && mtnet::bounds::u32(amount) &&
classic_sess->send_guild_u32(mtnet::classic::GUILD_CG_OFFER, (uint32_t)amount);
return game && is_in_game() && game->send_guild_sub_u32(mtnet::GUILD_CG_OFFER, (uint32_t)amount);
return game && is_in_game() && mtnet::bounds::u32(amount) &&
game->send_guild_sub_u32(mtnet::GUILD_CG_OFFER, (uint32_t)amount);
}
bool M2Client::guild_charge_gsp(int amount) {
if (amount < 0) return false;
if (!mtnet::bounds::i32(amount)) return false;
if (classic_sess) return is_in_game() &&
classic_sess->send_guild_i32(mtnet::classic::GUILD_CG_CHARGE_GSP, (int32_t)amount);
return game && is_in_game() &&
@@ -1602,21 +1798,21 @@ bool M2Client::guild_change_grade_name(int grade, const String &name) {
return game && is_in_game() && game->send_guild_grade_name((uint8_t)grade, value);
}
bool M2Client::guild_change_grade_authority(int grade, int authority) {
if (grade < 1 || grade >= 16 || authority < 0 || authority > 255) return false;
if (grade < 1 || grade >= 16 || !mtnet::bounds::u8(authority)) return false;
if (classic_sess) return is_in_game() &&
classic_sess->send_guild_grade_authority((uint8_t)grade, (uint8_t)authority);
return game && is_in_game() &&
game->send_guild_grade_authority((uint8_t)grade, (uint8_t)authority);
}
bool M2Client::guild_change_member_grade(int pid, int grade) {
if (pid <= 0 || grade < 1 || grade >= 16) return false;
if (pid <= 0 || !mtnet::bounds::u32(pid) || grade < 1 || grade >= 16) return false;
if (classic_sess) return is_in_game() &&
classic_sess->send_guild_member_grade((uint32_t)pid, (uint8_t)grade);
return game && is_in_game() &&
game->send_guild_member_grade((uint32_t)pid, (uint8_t)grade);
}
bool M2Client::guild_change_member_general(int pid, bool enabled) {
if (pid <= 0) return false;
if (pid <= 0 || !mtnet::bounds::u32(pid)) return false;
if (classic_sess) return is_in_game() &&
classic_sess->send_guild_member_general((uint32_t)pid, enabled);
return game && is_in_game() &&
@@ -1629,7 +1825,7 @@ bool M2Client::guild_post_comment(const String &text) {
return game && is_in_game() && game->send_guild_comment(comment);
}
bool M2Client::guild_delete_comment(int comment_id) {
if (comment_id <= 0) return false;
if (comment_id <= 0 || !mtnet::bounds::u32(comment_id)) return false;
if (classic_sess) return is_in_game() &&
classic_sess->send_guild_u32(mtnet::classic::GUILD_CG_DELETE_COMMENT,
(uint32_t)comment_id);
@@ -1642,7 +1838,7 @@ bool M2Client::guild_refresh_comments() {
return game && is_in_game() && game->send_guild_sub(mtnet::GUILD_CG_REFRESH_COMMENT);
}
bool M2Client::guild_answer_invite(int guild_id, bool accept) {
if (guild_id <= 0) return false;
if (guild_id <= 0 || !mtnet::bounds::u32(guild_id)) return false;
if (classic_sess) return is_in_game() &&
classic_sess->send_guild_invite_answer((uint32_t)guild_id, accept);
return game && is_in_game() && game->send_guild_invite_answer((uint32_t)guild_id, accept);
@@ -1726,10 +1922,11 @@ String M2Client::get_guild_name(int guild_id) const {
}
bool M2Client::use_guild_skill(int skill_vnum, int target_vid) {
if (classic_sess) return is_in_game() && skill_vnum >= 0 && target_vid >= 0 &&
classic_sess->send_guild_skill((uint32_t)skill_vnum, (uint32_t)target_vid);
return game && is_in_game() &&
game->send_guild_use_skill((uint32_t)skill_vnum, (uint32_t)target_vid);
if (!is_in_game() || !mtnet::bounds::u32(skill_vnum) || !mtnet::bounds::u32(target_vid)) {
return false;
}
if (classic_sess) return classic_sess->send_guild_skill((uint32_t)skill_vnum, (uint32_t)target_vid);
return game && game->send_guild_use_skill((uint32_t)skill_vnum, (uint32_t)target_vid);
}
bool M2Client::declare_guild_war(const String &guild_name) {
@@ -1739,7 +1936,7 @@ bool M2Client::declare_guild_war(const String &guild_name) {
// --- guild marks ---
bool M2Client::download_guild_marks(const String &host, int port) {
if ((!game && !classic_sess) || port <= 0 || port > 65535 || host.is_empty()) {
if ((!game && !classic_sess) || port <= 0 || !mtnet::bounds::u16(port) || host.is_empty()) {
return false;
}
mark_host = host;
@@ -1777,7 +1974,8 @@ bool M2Client::download_guild_marks(const String &host, int port) {
}
bool M2Client::download_guild_symbol(const String &host, int port, int guild_id) {
if ((!game && !classic_sess) || port <= 0 || port > 65535 || host.is_empty() || guild_id <= 0) {
if ((!game && !classic_sess) || port <= 0 || !mtnet::bounds::u16(port) || host.is_empty() ||
guild_id <= 0 || !mtnet::bounds::u32(guild_id)) {
return false;
}
symbol_guild_id = 0;
@@ -1838,7 +2036,8 @@ Dictionary M2Client::get_mark_server() const {
}
bool M2Client::upload_guild_mark(const String &host, int port, int guild_id, const Ref<Image> &img) {
if ((!game && !classic_sess) || port <= 0 || port > 65535 || host.is_empty() || img.is_null()) {
if ((!game && !classic_sess) || port <= 0 || !mtnet::bounds::u16(port) || host.is_empty() ||
guild_id <= 0 || !mtnet::bounds::u32(guild_id) || img.is_null()) {
return false;
}
Ref<Image> rgba = img;
@@ -1893,7 +2092,8 @@ bool M2Client::upload_guild_mark(const String &host, int port, int guild_id, con
bool M2Client::upload_guild_symbol(const String &host, int port, int guild_id,
const PackedByteArray &file_bytes) {
if ((!game && !classic_sess) || port <= 0 || port > 65535 || host.is_empty() || file_bytes.is_empty()) {
if ((!game && !classic_sess) || port <= 0 || !mtnet::bounds::u16(port) || host.is_empty() ||
guild_id <= 0 || !mtnet::bounds::u32(guild_id) || file_bytes.is_empty()) {
return false;
}
std::vector<uint8_t> bytes(file_bytes.ptr(), file_bytes.ptr() + file_bytes.size());
@@ -2059,9 +2259,11 @@ void M2Client::pump_classic_mark() {
}
}
bool M2Client::refine(int pos, int type) {
if (classic_sess) return is_in_game() && pos >= 0 && pos < mtnet::INVENTORY_MAX_NUM &&
type >= 0 && type <= 255 && classic_sess->send_refine((uint8_t)pos, (uint8_t)type);
return game && is_in_game() && game->send_refine((uint8_t)pos, (uint8_t)type);
if (!is_in_game() || pos < 0 || pos >= mtnet::INVENTORY_MAX_NUM || !mtnet::bounds::u8(type)) {
return false;
}
if (classic_sess) return classic_sess->send_refine((uint8_t)pos, (uint8_t)type);
return game && game->send_refine((uint8_t)pos, (uint8_t)type);
}
bool M2Client::ds_refine(int mode, const Array &cells) {
@@ -2077,7 +2279,7 @@ bool M2Client::ds_refine(int mode, const Array &cells) {
if (classic_sess) {
mtnet::classic::ItemPos grid[mtnet::classic::DS_REFINE_GRID_MAX_NUM] = {};
int n = (int)cells.size();
if (n > mtnet::classic::DS_REFINE_GRID_MAX_NUM) n = mtnet::classic::DS_REFINE_GRID_MAX_NUM;
if (n > mtnet::classic::DS_REFINE_GRID_MAX_NUM) return false;
for (int i = 0; i < n; ++i) {
int cell = (int)cells[i];
if (cell < 0 || cell > 0xffff) return false;
@@ -2088,11 +2290,10 @@ bool M2Client::ds_refine(int mode, const Array &cells) {
if (!game) return false;
mtnet::ItemPos grid[mtnet::DS_REFINE_WINDOW_MAX_NUM] = {};
int n = (int)cells.size();
if (n > mtnet::DS_REFINE_WINDOW_MAX_NUM) {
n = mtnet::DS_REFINE_WINDOW_MAX_NUM;
}
if (n > mtnet::DS_REFINE_WINDOW_MAX_NUM) return false;
for (int i = 0; i < n; ++i) {
int cell = (int)cells[i];
if (cell < 0 || cell > 0xffff) return false;
grid[i].window_type = mtnet::WINDOW_INVENTORY;
grid[i].cell = (uint16_t)cell;
}
@@ -2246,6 +2447,7 @@ static Dictionary entity_dict(const mtnet::Entity &e) {
d["level"] = e.level;
d["dead"] = e.dead;
d["stunned"] = e.stunned;
d["knock_down"] = e.knock_down;
d["mount_vnum"] = (int)e.mount_vnum;
d["guild"] = e.guild;
d["alignment"] = (int)e.alignment;
@@ -2324,6 +2526,11 @@ Dictionary M2Client::get_points() const {
d["next_exp"] = (int64_t)(uint32_t)p.next_exp();
d["gold"] = p.gold();
d["attack_speed"] = p.v[mtnet::POINT_ATT_SPEED];
d["bow_distance"] = p.v[mtnet::POINT_BOW_DISTANCE]; // __GetBowRange() 距离加成
// §3.10 加号按钮门控:三个技能点来源分开取(uicharacter.py skillPageStatDict)。
d["skill_active"] = p.v[mtnet::POINT_SKILL];
d["skill_support"] = p.v[mtnet::POINT_SUB_SKILL];
d["skill_horse"] = p.v[mtnet::POINT_HORSE_SKILL];
d["energy"] = p.energy();
d["energy_end_time"] = p.energy_end_time();
Array values;
@@ -2370,7 +2577,8 @@ Array M2Client::get_affects() const {
// --- items ---
bool M2Client::move_item(int fw, int fc, int tw, int tc, int count) {
if (!is_in_game()) {
if (!is_in_game() || !valid_item_pos(fw, fc) || !valid_item_pos(tw, tc) ||
!mtnet::bounds::u8(count)) {
return false;
}
if (classic_sess) {
@@ -2381,7 +2589,7 @@ bool M2Client::move_item(int fw, int fc, int tw, int tc, int count) {
(uint8_t)count);
}
bool M2Client::use_item(int window, int cell) {
if (!is_in_game()) {
if (!is_in_game() || !valid_item_pos(window, cell)) {
return false;
}
if (classic_sess) {
@@ -2390,7 +2598,7 @@ bool M2Client::use_item(int window, int cell) {
return game && game->send_item_use((uint8_t)window, (uint16_t)cell);
}
bool M2Client::drop_item(int window, int cell, int gold) {
if (!is_in_game()) {
if (!is_in_game() || !valid_item_pos(window, cell) || !mtnet::bounds::u32(gold)) {
return false;
}
if (classic_sess) {
@@ -2399,7 +2607,8 @@ bool M2Client::drop_item(int window, int cell, int gold) {
return game && game->send_item_drop((uint8_t)window, (uint16_t)cell, (uint32_t)gold);
}
bool M2Client::drop_item_count(int window, int cell, int gold, int count) {
if (!is_in_game() || count < 1 || count > 255) return false;
if (!is_in_game() || !valid_item_pos(window, cell) || !mtnet::bounds::u32(gold) ||
count < 1 || !mtnet::bounds::u8(count)) return false;
if (classic_sess) {
return classic_sess->send_item_drop((uint8_t)window, (uint16_t)cell, (uint32_t)gold,
(uint8_t)count);
@@ -2408,19 +2617,17 @@ bool M2Client::drop_item_count(int window, int cell, int gold, int count) {
(uint8_t)count);
}
bool M2Client::use_item_to_item(int sw, int sc, int tw, int tc) {
if (!is_in_game()) return false;
if (!is_in_game() || !valid_item_pos(sw, sc) || !valid_item_pos(tw, tc)) return false;
if (classic_sess) {
if (sw < 0 || sw > 255 || tw < 0 || tw > 255 || sc < 0 || sc > 0xffff || tc < 0 || tc > 0xffff)
return false;
return classic_sess->send_item_use_to_item((uint8_t)sw, (uint16_t)sc, (uint8_t)tw, (uint16_t)tc);
}
if (!game) return false;
return game->send_item_use_to_item((uint8_t)sw, (uint16_t)sc, (uint8_t)tw, (uint16_t)tc);
}
bool M2Client::give_item(int target_vid, int window, int cell, int count) {
if (!is_in_game() || target_vid <= 0 || count < 1 || count > 255) return false;
if (!is_in_game() || target_vid <= 0 || !mtnet::bounds::u32(target_vid) ||
!valid_item_pos(window, cell) || count < 1 || !mtnet::bounds::u8(count)) return false;
if (classic_sess) {
if (window < 0 || window > 255 || cell < 0 || cell > 0xffff) return false;
return classic_sess->send_item_give((uint32_t)target_vid, (uint8_t)window, (uint16_t)cell,
(uint8_t)count);
}
@@ -2428,7 +2635,7 @@ bool M2Client::give_item(int target_vid, int window, int cell, int count) {
return game->send_give_item((uint32_t)target_vid, (uint8_t)window, (uint16_t)cell, (uint8_t)count);
}
bool M2Client::pickup_item(int ground_vid) {
if (!is_in_game()) {
if (!is_in_game() || ground_vid <= 0 || !mtnet::bounds::u32(ground_vid)) {
return false;
}
if (classic_sess) {
@@ -2604,18 +2811,10 @@ void M2Client::_process(double) {
}
}
void M2Client::_notification(int what) {
switch (what) {
case NOTIFICATION_APPLICATION_PAUSED:
suspend();
break;
case NOTIFICATION_APPLICATION_RESUMED:
resume();
break;
default:
break;
}
}
// §9.3: M2Client no longer listens for NOTIFICATION_APPLICATION_PAUSED/RESUMED.
// AppFlow owns the single AppLifecycle coordinator (equivalent to
// CPythonApplication); it drives suspend()/resume() below. Keeping a second
// listener here meant two entry points for the same lifecycle edge.
void M2Client::suspend() {
if (suspended) {
+3 -1
View File
@@ -48,7 +48,6 @@ public:
~M2Client() override;
void _process(double delta) override;
void _notification(int what);
// host/port for auth and game servers; account credentials.
void connect_to_server(const godot::String &auth_host, int auth_port,
@@ -89,6 +88,9 @@ public:
bool attack(int motion, int victim_vid);
bool set_target(int victim_vid);
bool say(int type, const godot::String &text);
// Reference client SendEmoticon: send the registered chat token and let the
// presentation layer play the matching local effect.
bool send_emoticon(int index);
bool whisper(const godot::String &to, const godot::String &text);
// cast: CG_MOVE with func = FUNC_SKILL(0x80) | (motion_idx & 0x7F).
bool cast_skill(int motion_idx, double rot_deg, int x, int y);
+69
View File
@@ -0,0 +1,69 @@
#pragma once
// Public GDScript-facing network arguments arrive as signed integers. The
// wire structs use narrower unsigned fields, so every M2Client entry point
// must validate before converting instead of allowing a negative value to
// wrap into a large packet argument.
#include <cstdint>
#include <limits>
namespace mtnet::bounds {
inline constexpr bool u8(int64_t value) {
return value >= 0 && value <= std::numeric_limits<uint8_t>::max();
}
inline constexpr bool u16(int64_t value) {
return value >= 0 && value <= std::numeric_limits<uint16_t>::max();
}
inline constexpr bool u32(int64_t value) {
return value >= 0 && value <= std::numeric_limits<uint32_t>::max();
}
inline constexpr bool i32(int64_t value) {
return value >= std::numeric_limits<int32_t>::min() &&
value <= std::numeric_limits<int32_t>::max();
}
// Protocol geometry / array limits from ClientVS22 Packet.h and GameType.h.
inline constexpr int QUICK_SLOT_COUNT = 36;
inline constexpr int SHOP_ITEM_SLOT_COUNT = 40;
inline constexpr int PRIVATE_SHOP_ITEM_COUNT = 39;
inline constexpr int EXCHANGE_ITEM_SLOT_COUNT = 12;
inline constexpr int CUBE_ITEM_SLOT_COUNT = 24;
inline constexpr int POINT_COUNT = 255;
inline constexpr int DS_REFINE_SLOT_COUNT = 15;
// Packet.h:2041 SKILL_MAX_NUM; ClickSkillSlot rejects slot >= SKILL_MAX_NUM
// (PythonPlayerSkill.cpp:108) and the aSkill[] index is always below it.
inline constexpr int SKILL_MAX_NUM = 255;
inline constexpr bool quick_slot(int64_t value) {
return value >= 0 && value < QUICK_SLOT_COUNT;
}
inline constexpr bool shop_slot(int64_t value) {
return value >= 0 && value < SHOP_ITEM_SLOT_COUNT;
}
inline constexpr bool private_shop_slot(int64_t value) {
return value >= 0 && value < SHOP_ITEM_SLOT_COUNT;
}
inline constexpr bool exchange_slot(int64_t value) {
return value >= 0 && value < EXCHANGE_ITEM_SLOT_COUNT;
}
inline constexpr bool cube_slot(int64_t value) {
return value >= 0 && value < CUBE_ITEM_SLOT_COUNT;
}
// Real skill intent (CG_USE_SKILL vnum). Reference use path only ever sends a
// positive index below SKILL_MAX_NUM; a caller value outside that is rejected
// rather than masked into the uint32 wire field.
inline constexpr bool skill_index(int64_t value) {
return value > 0 && value < SKILL_MAX_NUM;
}
} // namespace mtnet::bounds
+4
View File
@@ -330,6 +330,10 @@ enum : uint8_t {
POINT_MAX_STAMINA = 10,
POINT_GOLD = 11,
POINT_ATT_SPEED = 17,
POINT_SUB_SKILL = 27, // 辅助/被动技能点 SKILL_SUB — uicharacter.py skillPageStatDict["SUPPORT"]
POINT_SKILL = 28, // 主动技能点 SKILL — skillPageStatDict["ACTIVE"]
POINT_BOW_DISTANCE = 34, // 활 사정거리 증가치 — __GetBowRange() bonus
POINT_HORSE_SKILL = 113, // 马术技能点 — skillPageStatDict["HORSE"] (Packet.h:1641)
POINT_ENERGY = 128,
POINT_ENERGY_END_TIME = 129,
};
+19 -2
View File
@@ -197,15 +197,32 @@ ItemRecord parse_item(const uint8_t *r, uint32_t stride) {
it.sub_type = r[139];
it.weight = r[140];
it.size = r[141];
it.anti_flags = rd_u32(r + 142);
it.flags = rd_u32(r + 146);
it.wear_flags = rd_u32(r + 150);
it.buy_price = rd_u32(r + 158);
it.sell_price = rd_u32(r + 162);
// aLimits[2] (5B each) @166, aApplies[3] (5B each) @176, alValues[6] @191.
// (bSpecular @234 anchors the whole chain.)
for (int i = 0; i < 2; ++i) {
const size_t off = 166 + static_cast<size_t>(i) * 5;
it.limits[i].type = r[off];
it.limits[i].value = static_cast<int32_t>(rd_u32(r + off + 1));
}
for (int i = 0; i < 3; ++i) {
const size_t off = 176 + static_cast<size_t>(i) * 5;
it.applies[i].type = r[off];
it.applies[i].value = static_cast<int32_t>(rd_u32(r + off + 1));
}
for (int i = 0; i < 6; ++i) {
it.values[i] = (int32_t)rd_u32(r + 191 + i * 4);
}
for (int i = 0; i < 3; ++i) {
it.sockets[i] = static_cast<int32_t>(rd_u32(r + 215 + i * 4));
}
it.refined_vnum = rd_u32(r + 227);
it.refine_set = static_cast<uint16_t>(r[231] | (static_cast<uint16_t>(r[232]) << 8));
it.alter_to_magic_pct = r[233];
it.specular = r[234];
it.gain_socket_pct = r[235];
return it;
}
+19
View File
@@ -43,6 +43,16 @@ bool load_proto(const std::string &path, const std::array<uint32_t, 4> &key, Pro
// --- typed views over the leading fields (rest is offset-stable per stride) ---
struct ItemLimit {
uint8_t type = 0;
int32_t value = 0;
};
struct ItemApply {
uint8_t type = 0;
int32_t value = 0;
};
struct ItemRecord {
uint32_t vnum = 0;
uint32_t vnum_range = 0;
@@ -52,11 +62,20 @@ struct ItemRecord {
uint8_t sub_type = 0; // @ 139
uint8_t weight = 0; // @ 140
uint8_t size = 0; // @ 141
uint32_t anti_flags = 0; // @ 142 (ITEM_ANTIFLAG_*; sell = 1 << 8)
uint32_t flags = 0; // @ 146 (ITEM_FLAG_*; count-per-1-gold = 1 << 3)
uint32_t wear_flags = 0; // @ 150
uint32_t buy_price = 0; // @ 158
uint32_t sell_price = 0; // @ 162
std::array<ItemLimit, 2> limits{}; // aLimits[2] @ 166 (type + long)
std::array<ItemApply, 3> applies{}; // aApplies[3] @ 176 (type + long)
int32_t values[6] = {0}; // alValues[6] @ 191 (armor: values[3] = body shape index)
int32_t sockets[3] = {0}; // alSockets[3] @ 215
uint32_t refined_vnum = 0; // dwRefinedVnum @ 227
uint16_t refine_set = 0; // wRefineSet @ 231
uint8_t alter_to_magic_pct = 0; // bAlterToMagicItemPct @ 233
uint8_t specular = 0; // @ 234 -> PARITY §2.7 fSpecular = specular/100
uint8_t gain_socket_pct = 0; // bGainSocketPct @ 235
};
ItemRecord parse_item(const uint8_t *rec, uint32_t stride);
+33
View File
@@ -71,9 +71,31 @@ Dictionary Metin2Proto::item(int vnum) const {
d["sub_type"] = (int)r.sub_type;
d["weight"] = (int)r.weight;
d["size"] = (int)r.size;
d["anti_flags"] = (int)r.anti_flags;
d["flags"] = (int)r.flags;
d["wear_flags"] = (int)r.wear_flags;
d["buy_price"] = (int)r.buy_price;
d["sell_price"] = (int)r.sell_price;
{
Array limits;
for (const mtproto::ItemLimit &limit : r.limits) {
Dictionary entry;
entry["type"] = (int)limit.type;
entry["value"] = (int)limit.value;
limits.push_back(entry);
}
d["limits"] = limits;
}
{
Array applies;
for (const mtproto::ItemApply &apply : r.applies) {
Dictionary entry;
entry["type"] = (int)apply.type;
entry["value"] = (int)apply.value;
applies.push_back(entry);
}
d["applies"] = applies;
}
{
Array vals;
for (int i = 0; i < 6; ++i) {
@@ -81,7 +103,18 @@ Dictionary Metin2Proto::item(int vnum) const {
}
d["values"] = vals; // armor: values[3] = body shape index for the race .msm
}
{
Array sockets;
for (int socket : r.sockets) {
sockets.push_back(socket);
}
d["sockets"] = sockets;
}
d["refined_vnum"] = (int)r.refined_vnum;
d["refine_set"] = (int)r.refine_set;
d["alter_to_magic_pct"] = (int)r.alter_to_magic_pct;
d["specular"] = (int)r.specular;
d["gain_socket_pct"] = (int)r.gain_socket_pct;
return d;
}
+3 -1
View File
@@ -445,7 +445,9 @@ Ref<ArrayMesh> build_proxy_impl(const std::string &species, float height_m,
leaf->set_shader(leaf_shader());
leaf->set_shader_parameter("leaf_tex",
textures.composite.is_valid() ? textures.composite : fallback_leaf_texture(conifer));
leaf->set_shader_parameter("wind_strength", 1.0f);
// MapUtil.cpp initializes fWindStrength to 0.2 and fWindRandom to 0;
// Environment_Load does not read a wind token from .msenv.
leaf->set_shader_parameter("wind_strength", 0.2f);
mesh->surface_set_material(1, leaf);
return mesh;
}