Complete remaining client gap features

This commit is contained in:
shenlei
2026-09-04 13:25:05 +09:00
parent 13ccb8d02d
commit 9a4a044da5
47 changed files with 6667 additions and 140 deletions
+9
View File
@@ -5,6 +5,7 @@ add_library(xr_formats STATIC
msm.cpp # 模型:base gr2 + 发型(RaceDataScript 子集)
msa.cpp # 动作:anim gr2 + duration + accumulation + 事件
combo_table.cpp # §3.5: playersettingmodule.py 的 ComboAttackNew -> PC 连击段表
attribute_data.cpp # .mdatr: 静态碰撞与高度三角网格
m2_tokvec.cpp # W0: LoadMultipleTextData 等价(Start/End 块 → token 向量表)
m2_coord.cpp # W0: Metin2↔Godot 坐标 / 单位 / 区块编号(BACKLOG I5
@@ -45,6 +46,14 @@ if(BUILD_TESTING)
ENVIRONMENT "M2_ASSETS=$ENV{M2_ASSETS}")
endif()
add_executable(formats_attribute_data_test tests/attribute_data_test.cpp)
target_link_libraries(formats_attribute_data_test PRIVATE xrender::formats)
add_test(NAME formats.attribute_data COMMAND formats_attribute_data_test)
if(DEFINED ENV{M2_ASSETS})
set_tests_properties(formats.attribute_data PROPERTIES
ENVIRONMENT "M2_ASSETS=$ENV{M2_ASSETS}")
endif()
add_executable(formats_map_test tests/map_formats_test.cpp)
target_link_libraries(formats_map_test PRIVATE xrender::formats)
add_test(NAME formats.map_formats COMMAND formats_map_test)
+165
View File
@@ -0,0 +1,165 @@
#include "attribute_data.h"
#include <cstring>
#include <fstream>
#include <limits>
namespace fmt {
namespace {
constexpr char kHeader[] = "AttributeData";
constexpr std::size_t kHeaderSize = sizeof(kHeader); // include trailing NUL
constexpr std::uint32_t kMaxRecords = 1u << 20;
constexpr std::uint32_t kMaxHeightVertices = 1u << 24;
struct Reader {
const std::uint8_t *data = nullptr;
std::size_t size = 0;
std::size_t offset = 0;
std::string *error = nullptr;
bool fail(const std::string &message) {
if (error)
*error = message + " at offset " + std::to_string(offset);
return false;
}
bool require(std::size_t count) {
if (count > size || offset > size - count)
return fail("AttributeData truncated");
return true;
}
template <typename T>
bool read(T &value) {
if (!require(sizeof(T)))
return false;
std::memcpy(&value, data + offset, sizeof(T));
offset += sizeof(T);
return true;
}
bool read_bytes(void *dst, std::size_t count) {
if (!require(count))
return false;
std::memcpy(dst, data + offset, count);
offset += count;
return true;
}
};
std::size_t dimension_count(std::uint32_t type) {
switch (static_cast<AttributeCollisionType>(type)) {
case AttributeCollisionType::Plane:
case AttributeCollisionType::Cylinder:
return 2;
case AttributeCollisionType::Box:
case AttributeCollisionType::Sphere:
case AttributeCollisionType::Aabb:
case AttributeCollisionType::Obb:
return type == static_cast<std::uint32_t>(AttributeCollisionType::Sphere) ? 1 : 3;
default:
return 0;
}
}
std::string fixed_name(const char (&raw)[32]) {
const void *nul = std::memchr(raw, '\0', 32);
const auto count = nul ? static_cast<const char *>(nul) - raw : 32;
return std::string(raw, static_cast<std::size_t>(count));
}
} // namespace
bool parse_attribute_data(const std::uint8_t *data, std::size_t size,
AttributeData &out, std::string *err) {
out = AttributeData{};
if (data == nullptr)
return err ? ((*err = "AttributeData buffer is null"), false) : false;
Reader r{data, size, 0, err};
char header[kHeaderSize] = {};
if (!r.read_bytes(header, kHeaderSize))
return false;
if (std::memcmp(header, kHeader, kHeaderSize) != 0)
return r.fail("AttributeData bad header");
std::uint32_t collision_count = 0;
std::uint32_t height_count = 0;
if (!r.read(collision_count) || !r.read(height_count))
return false;
if (collision_count > kMaxRecords || height_count > kMaxRecords)
return r.fail("AttributeData record count is unreasonable");
out.collisions.reserve(collision_count);
for (std::uint32_t i = 0; i < collision_count; ++i) {
AttributeCollision collision;
if (!r.read(collision.type))
return false;
const std::size_t dims = dimension_count(collision.type);
if (dims == 0)
return r.fail("AttributeData unknown collision type");
char raw_name[32] = {};
if (!r.read_bytes(raw_name, sizeof(raw_name)) ||
!r.read_bytes(collision.position.data(), sizeof(float) * 3) ||
!r.read_bytes(collision.dimensions.data(), sizeof(float) * dims) ||
!r.read_bytes(collision.quaternion.data(), sizeof(float) * 4))
return false;
collision.name = fixed_name(raw_name);
out.collisions.push_back(std::move(collision));
}
out.heights.reserve(height_count);
for (std::uint32_t i = 0; i < height_count; ++i) {
AttributeHeight height;
char raw_name[32] = {};
std::uint32_t vertex_count = 0;
if (!r.read_bytes(raw_name, sizeof(raw_name)) || !r.read(vertex_count))
return false;
if (vertex_count > kMaxHeightVertices)
return r.fail("AttributeData height vertex count is unreasonable");
if (vertex_count > 0 && vertex_count > (std::numeric_limits<std::size_t>::max() / 12))
return r.fail("AttributeData height size overflows");
if (!r.require(static_cast<std::size_t>(vertex_count) * 12))
return false;
height.name = fixed_name(raw_name);
height.vertices.resize(vertex_count);
for (auto &vertex : height.vertices) {
if (!r.read_bytes(vertex.data(), sizeof(float) * 3))
return false;
}
out.heights.push_back(std::move(height));
}
if (r.offset != size)
return r.fail("AttributeData trailing bytes");
return true;
}
bool parse_attribute_data_file(const std::string &path, AttributeData &out,
std::string *err) {
std::ifstream file(path, std::ios::binary | std::ios::ate);
if (!file) {
if (err)
*err = "cannot open AttributeData: " + path;
return false;
}
const std::streamoff end = file.tellg();
if (end < 0) {
if (err)
*err = "cannot stat AttributeData: " + path;
return false;
}
std::vector<std::uint8_t> bytes(static_cast<std::size_t>(end));
file.seekg(0, std::ios::beg);
if (!bytes.empty() && !file.read(reinterpret_cast<char *>(bytes.data()), static_cast<std::streamsize>(bytes.size()))) {
if (err)
*err = "cannot read AttributeData: " + path;
return false;
}
return parse_attribute_data(bytes.data(), bytes.size(), out, err);
}
} // namespace fmt
+56
View File
@@ -0,0 +1,56 @@
// .mdatr —— Metin2 AttributeData 静态碰撞 / 高度网格资源。
//
// The legacy client reads this file as a packed little-endian stream. Keep the
// format layer independent from Godot so it can be used by the world loader and
// by host-side validation tests alike.
#pragma once
#include <array>
#include <cstdint>
#include <string>
#include <vector>
namespace fmt {
enum class AttributeCollisionType : std::uint32_t {
Plane = 0,
Box = 1,
Sphere = 2,
Cylinder = 3,
Aabb = 4,
Obb = 5,
};
struct AttributeCollision {
std::uint32_t type = 0;
std::string name;
std::array<float, 3> position = {0.0f, 0.0f, 0.0f};
// Plane/Cylinder use dimensions[0..1], the other supported primitives use
// dimensions[0..2]. Values retain the source units (centimetres).
std::array<float, 3> dimensions = {0.0f, 0.0f, 0.0f};
// x, y, z, w in the same order as the D3DXQUATERNION on disk.
std::array<float, 4> quaternion = {0.0f, 0.0f, 0.0f, 1.0f};
};
struct AttributeHeight {
std::string name;
// The legacy file calls these vertices "primitives". They are a flat
// triangle list: every consecutive three vertices form one triangle.
std::vector<std::array<float, 3>> vertices;
};
struct AttributeData {
std::vector<AttributeCollision> collisions;
std::vector<AttributeHeight> heights;
};
// Parse the complete byte buffer. The parser rejects truncated data, unknown
// primitive types and unreasonable counts rather than accepting a partial
// collision resource.
bool parse_attribute_data(const std::uint8_t *data, std::size_t size,
AttributeData &out, std::string *err = nullptr);
bool parse_attribute_data_file(const std::string &path, AttributeData &out,
std::string *err = nullptr);
} // namespace fmt
+132
View File
@@ -0,0 +1,132 @@
#include "attribute_data.h"
#include <cmath>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <string>
#include <vector>
namespace {
int failures = 0;
#define CHECK(condition, message) \
do { \
if (!(condition)) { \
std::fprintf(stderr, "FAIL: %s (%s:%d)\n", (message), __FILE__, \
__LINE__); \
++failures; \
} \
} while (0)
template <typename T>
void append(std::vector<std::uint8_t> &bytes, const T &value) {
const auto *p = reinterpret_cast<const std::uint8_t *>(&value);
bytes.insert(bytes.end(), p, p + sizeof(T));
}
void append_name(std::vector<std::uint8_t> &bytes, const char *name) {
char fixed[32] = {};
std::strncpy(fixed, name, sizeof(fixed));
bytes.insert(bytes.end(), reinterpret_cast<std::uint8_t *>(fixed),
reinterpret_cast<std::uint8_t *>(fixed) + sizeof(fixed));
}
void append_collision(std::vector<std::uint8_t> &bytes, std::uint32_t type,
const char *name, const float *position,
const float *dimensions, const float *quaternion,
std::size_t dimension_count) {
append(bytes, type);
append_name(bytes, name);
for (std::size_t i = 0; i < 3; ++i)
append(bytes, position[i]);
for (std::size_t i = 0; i < dimension_count; ++i)
append(bytes, dimensions[i]);
for (std::size_t i = 0; i < 4; ++i)
append(bytes, quaternion[i]);
}
void test_synthetic() {
std::vector<std::uint8_t> bytes;
const char header[] = "AttributeData";
bytes.insert(bytes.end(), header, header + sizeof(header));
const std::uint32_t collisions = 3;
const std::uint32_t heights = 1;
append(bytes, collisions);
append(bytes, heights);
const float p0[] = {10, 20, 30}, d0[] = {40, 50}, q0[] = {0, 0, 0, 1};
append_collision(bytes, 0, "plane", p0, d0, q0, 2);
const float p1[] = {-1, 2, -3}, d1[] = {7}, q1[] = {1, 2, 3, 4};
append_collision(bytes, 2, "sphere", p1, d1, q1, 1);
const float p2[] = {4, 5, 6}, d2[] = {8, 9, 10}, q2[] = {0, 0.5f, 0, 0.5f};
append_collision(bytes, 5, "obb", p2, d2, q2, 3);
append_name(bytes, "height0");
const std::uint32_t vertex_count = 3;
append(bytes, vertex_count);
const float vertices[] = {0, 0, 0, 100, 0, 0, 0, 100, 0};
for (float value : vertices)
append(bytes, value);
fmt::AttributeData parsed;
std::string error;
CHECK(fmt::parse_attribute_data(bytes.data(), bytes.size(), parsed, &error),
"synthetic .mdatr parses");
CHECK(parsed.collisions.size() == 3 && parsed.heights.size() == 1,
"synthetic counts");
CHECK(parsed.collisions[0].name == "plane" &&
parsed.collisions[0].dimensions[1] == 50.0f,
"plane fields");
CHECK(parsed.collisions[1].type == 2 &&
parsed.collisions[1].dimensions[0] == 7.0f &&
parsed.collisions[1].quaternion[0] == 1.0f,
"sphere fields");
CHECK(parsed.collisions[2].name == "obb" &&
parsed.collisions[2].dimensions[2] == 10.0f,
"obb fields");
CHECK(parsed.heights[0].name == "height0" &&
parsed.heights[0].vertices.size() == 3 &&
parsed.heights[0].vertices[1][0] == 100.0f,
"height triangle fields");
auto truncated = bytes;
truncated.pop_back();
CHECK(!fmt::parse_attribute_data(truncated.data(), truncated.size(), parsed, &error),
"truncated .mdatr rejected");
auto trailing = bytes;
trailing.push_back(0);
CHECK(!fmt::parse_attribute_data(trailing.data(), trailing.size(), parsed, &error),
"trailing .mdatr rejected");
}
void test_real_asset() {
const char *root = std::getenv("M2_ASSETS");
if (!root || !*root)
return;
const std::string path = std::string(root) +
"/metin2_patch_eu3/ymir work/zone/devilcave/devil_entrance1f.mdatr";
fmt::AttributeData parsed;
std::string error;
CHECK(fmt::parse_attribute_data_file(path, parsed, &error),
"real devil_entrance1f.mdatr parses");
CHECK(parsed.collisions.size() == 9 && parsed.heights.empty(),
"real .mdatr collision/height count");
CHECK(parsed.collisions[0].type == 0 && parsed.collisions[0].name == "collision04",
"real .mdatr first collision");
}
} // namespace
int main() {
test_synthetic();
test_real_asset();
if (failures == 0) {
std::puts("PASS: formats_attribute_data_test");
return 0;
}
std::fprintf(stderr, "%d check(s) failed\n", failures);
return 1;
}