完善客户端功能并同步差距文档
This commit is contained in:
@@ -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 是独立六面 quad;Godot 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;
|
||||
}
|
||||
|
||||
@@ -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 cover;texture-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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user