Complete remaining client gap features
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user