cleanup: remove Godot-era test data, formats/, dead proto reader and duplicates

- test/: bgfx/Godot render goldens and compare shots nothing reads any more;
  the Granny oracle result m2-numeric.json moves to oracle/
- formats/: only the .spt sniffer was still linked; it moves into
  platform/SpeedTreeLib, the rest and tools/map_probe go
- bgm/ duplicated Client/BGM byte for byte; tools/root archived root scripts
  that root.epk already holds; extension/src/proto had no build reference
- GEMINI.md duplicated AGENTS.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
shenlei
2026-09-29 18:43:19 +09:00
co-authored by Claude Opus 5.5
parent 0137f852d3
commit 70710477cf
198 changed files with 8 additions and 69264 deletions
+1 -15
View File
@@ -24,12 +24,7 @@ __pycache__/
*.pyc
.env.live-smoke.local
# playable network tests: local scenario/fixture configs, credential files and
# run outputs never enter git. Only scenario.example.json and the static gate
# fixtures in test/playable are committed. Run outputs live under /build/playable.
/test/playable/*.local.json
/test/playable/*.local.txt
/test/playable/runs/
# credential files never enter git.
*.credentials
*.credentials.json
.env.*.local
@@ -44,17 +39,8 @@ __pycache__/
/oracle/*.dylib
# vendored-lib test artifacts
/test/oracle_dumps/
/test/*.actual.png
/build-tools/
# libgr2 tool outputs
/fuzz-report.json
/test/m2-numeric.json.tmp
# compare.py render output
/test/compare-shots/
/test/compare-report.json
# Written by 40250 uitaskbar.py at runtime.
/mouse.cfg
-1
View File
@@ -17,7 +17,6 @@ option(MT_BUILD_PORT "Build the 40250 port and its tests" ON)
option(MT_BUILD_NATIVE_RENDER "Build the SDL3 and Vulkan client" OFF)
add_subdirectory(libgr2)
add_subdirectory(formats)
add_subdirectory(extension/third_party)
add_subdirectory(extension/src/codepage)
if(MT_BUILD_PORT OR MT_BUILD_NATIVE_RENDER)
-3
View File
@@ -1,3 +0,0 @@
# Project instructions
For any task involving Metin2 40250 comparison, 1:1 parity, missing original-client behavior, or parity regression, first read `.agents/skills/metin2-40250-parity-audit/SKILL.md` completely and follow it. Use `audit/manifest.json` as the persistent audit state and do not repeat still-valid verified work.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
+1 -2
View File
@@ -10,8 +10,7 @@ if(NOT TARGET mtpython)
list(FILTER MT_PLATFORM_SOURCES EXCLUDE REGEX "/ScriptLib/")
endif()
add_library(port_platform STATIC ${MT_PLATFORM_SOURCES})
target_link_libraries(port_platform PUBLIC port_logic PRIVATE mt3p::freetype xrender::libgr2 xrender::formats)
target_include_directories(port_platform PRIVATE ${CMAKE_SOURCE_DIR}/formats)
target_link_libraries(port_platform PUBLIC port_logic PRIVATE mt3p::freetype xrender::libgr2)
foreach(src IN LISTS MT_PLATFORM_SOURCES)
mt_port_apply_defines(${src})
endforeach()
@@ -21,7 +21,7 @@
#include "SpeedTreeLib/StdAfx.h"
#include <SpeedTreeRT.h>
#include <spt.h>
#include "spt.h"
#include "../EterLib/RenderCommands3D.h"
@@ -1,7 +1,5 @@
#include "spt.h"
#include "m2_tokvec.h" // read_file
#include <cctype>
#include <cstdint>
#include <cstring>
@@ -91,10 +89,5 @@ bool sniff_spt(const std::string& b, SptInfo& out) {
return out.ok;
}
bool sniff_spt_file(const std::string& path, SptInfo& out) {
std::string b;
if (!read_file(path, b)) return false;
return sniff_spt(b, out);
}
} // namespace fmt
@@ -11,6 +11,7 @@
// 这里只做「嗅探」:读文件头 magic + version,抽取内嵌贴图和 composite atlas token。
// 不解析枝干/叶片几何。无 godot 依赖。
#pragma once
#include <cstdint>
#include <string>
#include <vector>
@@ -29,6 +30,5 @@ struct SptInfo {
};
bool sniff_spt(const std::string& bytes, SptInfo& out);
bool sniff_spt_file(const std::string& path, SptInfo& out);
} // namespace fmt
-253
View File
@@ -1,253 +0,0 @@
#include "proto.h"
#include "EterBase/StdAfx.h"
#include "EterBase/lzo.h"
#include "GameLib/StdAfx.h"
#include "GameLib/ItemData.h"
#include "../platform/UserInterface/UserInterface.h"
#include <cstring>
#include <fstream>
namespace mtproto {
namespace {
// CPythonNonPlayer::SMobTable (UserInterface/PythonNonPlayer.h), the 40250 ILP32 layout. The mirror of that header
// needs UserInterface/StdAfx.h, which includes the Python headers (after batch 2P); until then the table is here.
#pragma pack(push, 1)
struct SMobSkillLevel {
DWORD dwVnum;
BYTE bLevel;
};
struct SMobTable {
DWORD dwVnum;
char szName[24 + 1]; // CHARACTER_NAME_MAX_LEN (UserInterface/StdAfx.h)
char szLocaleName[24 + 1];
BYTE bType;
BYTE bRank;
BYTE bBattleType;
BYTE bLevel;
BYTE bSize;
DWORD dwGoldMin;
DWORD dwGoldMax;
DWORD dwExp;
DWORD dwMaxHP;
BYTE bRegenCycle;
BYTE bRegenPercent;
WORD wDef;
DWORD dwAIFlag;
DWORD dwRaceFlag;
DWORD dwImmuneFlag;
BYTE bStr, bDex, bCon, bInt;
DWORD dwDamageRange[2];
short sAttackSpeed;
short sMovingSpeed;
BYTE bAggresiveHPPct;
WORD wAggressiveSight;
WORD wAttackRange;
char cEnchants[6]; // MOB_ENCHANTS_MAX_NUM
char cResists[11]; // MOB_RESISTS_MAX_NUM
DWORD dwResurrectionVnum;
DWORD dwDropItemVnum;
BYTE bMountCapacity;
BYTE bOnClickType;
BYTE bEmpire;
char szFolder[64 + 1];
float fDamMultiply;
DWORD dwSummonVnum;
DWORD dwDrainSP;
DWORD dwMonsterColor;
DWORD dwPolymorphItemVnum;
SMobSkillLevel Skills[5]; // MOB_SKILL_MAX_NUM
BYTE bBerserkPoint;
BYTE bStoneSkinPoint;
BYTE bGodSpeedPoint;
BYTE bDeathBlowPoint;
BYTE bRevivePoint;
};
#pragma pack(pop)
static_assert(sizeof(SMobTable) == 255, "mob_proto record");
using TItemTable = CItemData::TItemTable;
uint32_t rd_u32(const uint8_t *p) {
uint32_t v;
std::memcpy(&v, p, 4);
return v;
}
std::string cstr(const char *p, size_t maxn) {
size_t n = 0;
while (n < maxn && p[n]) {
++n;
}
return std::string(p, n);
}
} // namespace
bool load_proto(const std::string &path, const std::array<uint32_t, 4> &key, Proto &out,
std::string *err) {
std::ifstream f(path, std::ios::binary);
if (!f) {
if (err) *err = "cannot open " + path;
return false;
}
std::vector<uint8_t> file((std::istreambuf_iterator<char>(f)), std::istreambuf_iterator<char>());
return load_proto_bytes(file, key, out, err);
}
// The header checks of CItemManager::LoadItemTable / CPythonNonPlayer::LoadNonPlayerData, then CLZO::Decompress.
bool load_proto_bytes(const std::vector<uint8_t> &file, const std::array<uint32_t, 4> &key,
Proto &out, std::string *err) {
out = Proto{};
if (file.size() < 12) {
if (err) *err = "file too small";
return false;
}
size_t pos = 0;
auto read_u32 = [&](uint32_t &v) {
if (pos + 4 > file.size()) {
return false;
}
v = rd_u32(file.data() + pos);
pos += 4;
return true;
};
read_u32(out.fourcc);
if (out.fourcc == MAKEFOURCC('M', 'I', 'P', 'X')) {
if (!read_u32(out.version) || !read_u32(out.stride)) {
if (err) *err = "truncated MIPX header";
return false;
}
if (out.version != 1) {
if (err) *err = "invalid item_proto VERSION " + std::to_string(out.version);
return false;
}
if (out.stride != sizeof(TItemTable)) {
if (err) *err = "invalid item_proto STRIDE " + std::to_string(out.stride) + " != sizeof(SItemTable)";
return false;
}
} else if (out.fourcc == MAKEFOURCC('M', 'I', 'P', 'T')) {
out.stride = sizeof(TItemTable);
} else if (out.fourcc == MAKEFOURCC('M', 'M', 'P', 'T')) {
out.stride = sizeof(SMobTable);
} else {
if (err) *err = "invalid proto type";
return false;
}
uint32_t data_size = 0;
if (!read_u32(out.elements) || !read_u32(data_size)) {
if (err) *err = "truncated proto header";
return false;
}
// 40250 reads dwDataSize bytes without checking; CLZO then reads its own header lengths.
if (pos + data_size > file.size() || data_size < sizeof(CLZObject::THeader)) {
if (err) *err = "declared data_size past end of file";
return false;
}
PackSingletons(); // CLZO, as 40250 Main() creates it
DWORD dwKey[4] = {key[0], key[1], key[2], key[3]};
CLZObject zObj;
if (!CLZO::Instance().Decompress(zObj, file.data() + pos, dwKey)) {
if (err) *err = "CLZO::Decompress failed (wrong key or corrupt data)";
return false;
}
if (out.fourcc == MAKEFOURCC('M', 'M', 'P', 'T') && (zObj.GetSize() % sizeof(SMobTable)) != 0) {
if (err) *err = "invalid mob_proto size " + std::to_string(zObj.GetSize()) + ", check data format";
return false;
}
// 40250 walks dwElements records from the buffer without comparing against its size.
if (static_cast<uint64_t>(out.stride) * out.elements > zObj.GetSize()) {
if (err) *err = "elements * stride past decompressed size";
return false;
}
const BYTE *buf = zObj.GetBuffer();
out.blob.assign(buf, buf + static_cast<size_t>(out.stride) * out.elements);
return true;
}
ItemRecord parse_item(const uint8_t *r, uint32_t stride) {
ItemRecord it;
if (!r || stride != sizeof(TItemTable)) {
return it;
}
TItemTable t;
std::memcpy(&t, r, sizeof(t));
it.vnum = t.dwVnum;
it.vnum_range = t.dwVnumRange;
it.name = cstr(t.szName, sizeof(t.szName));
it.locale_name = cstr(t.szLocaleName, sizeof(t.szLocaleName));
it.type = t.bType;
it.sub_type = t.bSubType;
it.weight = t.bWeight;
it.size = t.bSize;
it.anti_flags = t.dwAntiFlags;
it.flags = t.dwFlags;
it.wear_flags = t.dwWearFlags;
it.immune_flag = t.dwImmuneFlag;
it.buy_price = t.dwIBuyItemPrice;
it.sell_price = t.dwISellItemPrice;
for (int i = 0; i < CItemData::ITEM_LIMIT_MAX_NUM; ++i) {
it.limits[i] = {t.aLimits[i].bType, t.aLimits[i].lValue};
}
for (int i = 0; i < CItemData::ITEM_APPLY_MAX_NUM; ++i) {
it.applies[i] = {t.aApplies[i].bType, t.aApplies[i].lValue};
}
for (int i = 0; i < CItemData::ITEM_VALUES_MAX_NUM; ++i) {
it.values[i] = t.alValues[i];
}
for (int i = 0; i < CItemData::ITEM_SOCKET_MAX_NUM; ++i) {
it.sockets[i] = t.alSockets[i];
}
it.refined_vnum = t.dwRefinedVnum;
it.refine_set = t.wRefineSet;
it.alter_to_magic_pct = t.bAlterToMagicItemPct;
it.specular = t.bSpecular;
it.gain_socket_pct = t.bGainSocketPct;
return it;
}
MobRecord parse_mob(const uint8_t *r, uint32_t stride) {
MobRecord m;
if (!r || stride != sizeof(SMobTable)) {
return m;
}
SMobTable t;
std::memcpy(&t, r, sizeof(t));
m.vnum = t.dwVnum;
m.name = cstr(t.szName, sizeof(t.szName));
m.locale_name = cstr(t.szLocaleName, sizeof(t.szLocaleName));
m.type = t.bType;
m.rank = t.bRank;
m.battle_type = t.bBattleType;
m.level = t.bLevel;
m.size = t.bSize;
m.attack_speed = t.sAttackSpeed;
m.moving_speed = t.sMovingSpeed;
m.on_click_type = t.bOnClickType;
m.empire = t.bEmpire;
m.folder = cstr(t.szFolder, sizeof(t.szFolder));
m.monster_color = t.dwMonsterColor;
return m;
}
} // namespace mtproto
-104
View File
@@ -1,104 +0,0 @@
#pragma once
// item_proto / mob_proto reader, the 40250 format (GameLib/ItemManager.cpp CItemManager::LoadItemTable,
// UserInterface/PythonNonPlayer.cpp CPythonNonPlayer::LoadNonPlayerData).
//
// Outer: item = [u32 'MIPX'][u32 ver=1][u32 stride=156][u32 elements][u32 datasize][CLZObject]
// (or the older [u32 'MIPT'][u32 elements][u32 datasize][CLZObject])
// mob = [u32 'MMPT'][u32 elements][u32 datasize][CLZObject]
// CLZObject = [u32 'MCOZ'][u32 encryptSize][u32 compressedSize][u32 realSize] + TEA/LZO payload, decoded by the
// ported CLZO::Decompress with the table's key. Records are #pragma pack(1) ILP32 structs:
// CItemData::TItemTable (156 bytes, port/GameLib/ItemData.h) and CPythonNonPlayer::TMobTable (255 bytes).
#include <array>
#include <cstdint>
#include <string>
#include <vector>
namespace mtproto {
// The 4-DWORD keys 40250 hardcodes: s_adwItemProtoKey (GameLib/ItemManager.cpp), s_adwMobProtoKey
// (UserInterface/PythonNonPlayer.cpp).
inline constexpr std::array<uint32_t, 4> ITEM_PROTO_KEY = {173217u, 72619434u, 408587239u, 27973291u};
inline constexpr std::array<uint32_t, 4> MOB_PROTO_KEY = {4813894u, 18955u, 552631u, 6822045u};
struct Proto {
uint32_t fourcc = 0;
uint32_t version = 0;
uint32_t stride = 0; // record size: sizeof(TItemTable) or sizeof(TMobTable)
uint32_t elements = 0; // record count
std::vector<uint8_t> blob; // elements * stride bytes
const uint8_t *record(uint32_t i) const {
return (i < elements) ? blob.data() + static_cast<size_t>(i) * stride : nullptr;
}
};
// Load + decompress. `key` is ITEM_PROTO_KEY or MOB_PROTO_KEY; the format follows the fourcc.
// Parse from an already-read buffer (the extension reads through asset_io, e.g. pack://locale/en/item_proto).
bool load_proto_bytes(const std::vector<uint8_t> &bytes, const std::array<uint32_t, 4> &key,
Proto &out, std::string *err);
bool load_proto(const std::string &path, const std::array<uint32_t, 4> &key, Proto &out,
std::string *err = nullptr);
// --- typed views over the records ---
struct ItemLimit {
uint8_t type = 0;
int32_t value = 0;
};
struct ItemApply {
uint8_t type = 0;
int32_t value = 0;
};
// CItemData::TItemTable, field for field.
struct ItemRecord {
uint32_t vnum = 0;
uint32_t vnum_range = 0;
std::string name; // szName[25]
std::string locale_name; // szLocaleName[25]
uint8_t type = 0;
uint8_t sub_type = 0;
uint8_t weight = 0;
uint8_t size = 0;
uint32_t anti_flags = 0; // ITEM_ANTIFLAG_*
uint32_t flags = 0; // ITEM_FLAG_*
uint32_t wear_flags = 0;
uint32_t immune_flag = 0;
uint32_t buy_price = 0; // dwIBuyItemPrice
uint32_t sell_price = 0; // dwISellItemPrice
std::array<ItemLimit, 2> limits{};
std::array<ItemApply, 3> applies{};
int32_t values[6] = {0}; // alValues (armor: values[3] = body shape index)
int32_t sockets[3] = {0};
uint32_t refined_vnum = 0;
uint16_t refine_set = 0;
uint8_t alter_to_magic_pct = 0;
uint8_t specular = 0; // PARITY §2.7 fSpecular = specular/100
uint8_t gain_socket_pct = 0;
};
// Empty record when `stride` is not sizeof(TItemTable).
ItemRecord parse_item(const uint8_t *rec, uint32_t stride);
// The leading CPythonNonPlayer::TMobTable fields plus the ones the client reads for display.
struct MobRecord {
uint32_t vnum = 0;
std::string name; // szName[25]
std::string locale_name; // szLocaleName[25]
uint8_t type = 0;
uint8_t rank = 0;
uint8_t battle_type = 0;
uint8_t level = 0;
uint8_t size = 0;
int16_t attack_speed = 0;
int16_t moving_speed = 0;
uint8_t on_click_type = 0;
uint8_t empire = 0;
std::string folder; // szFolder[65]
uint32_t monster_color = 0;
};
// Empty record when `stride` is not sizeof(TMobTable).
MobRecord parse_mob(const uint8_t *rec, uint32_t stride);
} // namespace mtproto
-65
View File
@@ -1,65 +0,0 @@
# msm / msa —— Metin2 文本包装格式(M2)。照 EterGrnLib/RaceManager 复刻。
# m2map/* —— A1 世界渲染的地图格式(SHINSOO-WORLD-RENDERING.md W0)。无 godot 依赖。
add_library(xr_formats STATIC
textscript.cpp # M2 T1: Metin2 文本脚本 token 树解析(.msa / .msm 共用)
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)
map_setting.cpp # W0: setting.txt
texture_set.cpp # W0: TextureSet .txt
area_data.cpp # W0: areadata / areaambiencedata / areaproperty
terrain_files.cpp # W0: height.raw / tile.raw / attr.atr / water.wtr
environment.cpp # W0: .msenv(Group/List 文本树)
property.cpp # W0: .prb/.prt/.pre/.prd/.pra + CRC 注册表(BACKLOG E13)
asset_resolver.cpp # W0: 虚拟路径 -> 散文件(BACKLOG G7)
terrain_mesh.cpp # W1: heightmap -> 顶点/法线/索引 + GetHeight 三角插值
splat.cpp # W2: tile.raw -> 每图层 258x258 alpha(RAW_GenerateSplat 端口)
spt.cpp # W4: .spt 嗅探(几何不可读,见文件头注释)
)
add_library(xrender::formats ALIAS xr_formats)
target_include_directories(xr_formats PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
target_link_libraries(xr_formats PUBLIC xrender::libgr2)
target_compile_features(xr_formats PUBLIC cxx_std_20)
if(BUILD_TESTING)
add_executable(formats_msa_test tests/msa_test.cpp)
target_link_libraries(formats_msa_test PRIVATE xrender::formats)
add_test(NAME formats.msa_loop_data COMMAND formats_msa_test)
if(DEFINED ENV{M2_ASSETS})
set_tests_properties(formats.msa_loop_data PROPERTIES
ENVIRONMENT "M2_ASSETS=$ENV{M2_ASSETS}")
endif()
add_executable(formats_msm_test tests/msm_test.cpp)
target_link_libraries(formats_msm_test PRIVATE xrender::formats)
add_test(NAME formats.msm_hair COMMAND formats_msm_test)
add_executable(formats_combo_table_test tests/combo_table_test.cpp)
target_link_libraries(formats_combo_table_test PRIVATE xrender::formats)
add_test(NAME formats.combo_table COMMAND formats_combo_table_test)
if(DEFINED ENV{M2_ASSETS})
set_tests_properties(formats.combo_table PROPERTIES
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)
# 真实 A1 资源路径(可选):设了就跑 live 断言。
if(DEFINED ENV{M2_ASSETS})
set_tests_properties(formats.map_formats PROPERTIES
ENVIRONMENT "M2_ASSETS=$ENV{M2_ASSETS}")
endif()
endif()
-132
View File
@@ -1,132 +0,0 @@
#include "area_data.h"
#include "m2_tokvec.h"
#include <cstdio>
#include <cstdlib>
namespace fmt {
namespace {
// "yaw#pitch#roll" 或单个数(= roll)。原客户端 Area.cpp 用 atoi,这里用 atof 更宽容。
void parse_ypr(const std::string& s, float& yaw, float& pitch, float& roll) {
yaw = pitch = roll = 0;
auto h1 = s.find('#');
if (h1 == std::string::npos) {
roll = (float)std::atof(s.c_str());
return;
}
auto h2 = s.find('#', h1 + 1);
yaw = (float)std::atof(s.substr(0, h1).c_str());
if (h2 == std::string::npos) {
pitch = (float)std::atof(s.substr(h1 + 1).c_str());
} else {
pitch = (float)std::atof(s.substr(h1 + 1, h2 - h1 - 1).c_str());
roll = (float)std::atof(s.substr(h2 + 1).c_str());
}
}
} // namespace
bool parse_area_data(const std::string& text, AreaData& out, std::string* err) {
TokVecMap tv;
std::string perr;
parse_tokvec(text, tv, &perr);
out = AreaData{};
if (!tv.has("areadatafile")) {
if (err) *err = "areadata 缺首行 `AreaDataFile`";
return false;
}
if (!tv.has("objectcount")) {
if (err) *err = "areadata 缺 ObjectCount";
return false;
}
out.declared_count = (int)tv.inum("objectcount");
for (int i = 0; i < out.declared_count; ++i) {
char key[32];
std::snprintf(key, sizeof(key), "object%03d", i);
const auto* v = tv.find(key);
if (!v) continue; // 原客户端也是 `continue`
if (v->size() < 4) {
if (err) *err = std::string("areadata 块 ") + key + " token 不足 4 个";
return false;
}
AreaObject o;
o.x = std::atof((*v)[0].c_str());
o.y = std::atof((*v)[1].c_str());
o.z = std::atof((*v)[2].c_str());
o.crc = (uint32_t)std::strtoul((*v)[3].c_str(), nullptr, 10);
if (v->size() > 4) parse_ypr((*v)[4], o.yaw, o.pitch, o.roll);
if (v->size() > 5) o.height_bias = (float)std::atof((*v)[5].c_str());
for (size_t k = 6; k < v->size(); ++k)
o.portal_ids.push_back(std::atoi((*v)[k].c_str()));
out.objects.push_back(std::move(o));
}
return true;
}
bool parse_area_ambience(const std::string& text, AreaAmbienceData& out, std::string* err) {
TokVecMap tv;
std::string perr;
parse_tokvec(text, tv, &perr);
out = AreaAmbienceData{};
if (!tv.has("areaambiencedatafile")) {
if (err) *err = "areaambiencedata 缺首行";
return false;
}
if (!tv.has("objectcount")) {
if (err) *err = "areaambiencedata 缺 ObjectCount";
return false;
}
out.declared_count = (int)tv.inum("objectcount");
for (int i = 0; i < out.declared_count; ++i) {
char key[32];
std::snprintf(key, sizeof(key), "object%03d", i);
const auto* v = tv.find(key);
if (!v || v->size() < 5) continue;
AreaAmbienceObject o;
o.x = std::atof((*v)[0].c_str());
o.y = std::atof((*v)[1].c_str());
o.z = std::atof((*v)[2].c_str());
o.crc = (uint32_t)std::strtoul((*v)[3].c_str(), nullptr, 10);
o.range = (float)std::atof((*v)[4].c_str());
out.objects.push_back(std::move(o));
}
return true;
}
bool parse_area_property(const std::string& text, AreaProperty& out, std::string* err) {
TokVecMap tv;
std::string perr;
parse_tokvec(text, tv, &perr);
out = AreaProperty{};
out.area_name = tv.str("areaname");
out.num_water = (int)tv.inum("numwater", 0);
(void)err;
return true;
}
// --- file wrappers ---
static bool load(const std::string& path, std::string& text, std::string* err) {
if (!read_file(path, text)) {
if (err) *err = "打不开 " + path;
return false;
}
return true;
}
bool parse_area_data_file(const std::string& p, AreaData& o, std::string* e) {
std::string t; return load(p, t, e) && parse_area_data(t, o, e);
}
bool parse_area_ambience_file(const std::string& p, AreaAmbienceData& o, std::string* e) {
std::string t; return load(p, t, e) && parse_area_ambience(t, o, e);
}
bool parse_area_property_file(const std::string& p, AreaProperty& o, std::string* e) {
std::string t; return load(p, t, e) && parse_area_property(t, o, e);
}
} // namespace fmt
-54
View File
@@ -1,54 +0,0 @@
// areadata.txt / areaambiencedata.txt / areaproperty.txt —— 每个区块的对象记录。
// 对标 GameLib/Area.cpp __Load_LoadObject / __Load_LoadAmbience 和
// MapOutdoorLoad.cpp 的 AreaProperty 解析。SHINSOO §5.3 / §5.6。
//
// 坐标系(W0 判明 → W3 修正,2026-08-29):areadata 的 position 是**地图全局厘米**(不是
// 区块本地!只有 000000 因为在原点附近才看着像本地)。x 正 = 东,y 负 = 南(幅度 = 距地图
// 北边缘),随 tile_y 增大而更负。`Area::SetCoordinate` 不偏移,正因为 position 已是全局。
// 各区块 x∈[tile_x·25600, +25600]、|y|∈[tile_y·25600, +25600](实测 A1 全 20 区块)。
// z 是竖直(高度)轴 cm。heightBias 加在 z 上。转换:直接 position_to_godot(x, y, z)。
#pragma once
#include <cstdint>
#include <string>
#include <vector>
namespace fmt {
struct AreaObject {
double x = 0, y = 0, z = 0; // 区块本地 cm
uint32_t crc = 0; // property CRC
float yaw = 0, pitch = 0, roll = 0;
float height_bias = 0;
std::vector<int> portal_ids;
};
struct AreaData {
int declared_count = 0; // ObjectCount
std::vector<AreaObject> objects; // 实际解析到的(缺块会跳过,故可能 < declared)
};
struct AreaAmbienceObject {
double x = 0, y = 0, z = 0;
uint32_t crc = 0;
float range = 0;
};
struct AreaAmbienceData {
int declared_count = 0;
std::vector<AreaAmbienceObject> objects;
};
struct AreaProperty {
std::string area_name; // AreaName(去引号)
int num_water = 0;
};
bool parse_area_data(const std::string& text, AreaData& out, std::string* err);
bool parse_area_data_file(const std::string& path, AreaData& out, std::string* err);
bool parse_area_ambience(const std::string& text, AreaAmbienceData& out, std::string* err);
bool parse_area_ambience_file(const std::string& path, AreaAmbienceData& out, std::string* err);
bool parse_area_property(const std::string& text, AreaProperty& out, std::string* err);
bool parse_area_property_file(const std::string& path, AreaProperty& out, std::string* err);
} // namespace fmt
-261
View File
@@ -1,261 +0,0 @@
#include "asset_resolver.h"
#include "m2_tokvec.h" // fmt::read_file(host 可注入 FileAccess)
#include <algorithm>
#include <cctype>
#include <filesystem>
#include <sstream>
namespace fmt {
namespace fs = std::filesystem;
namespace {
std::string lower(std::string s) {
std::transform(s.begin(), s.end(), s.begin(),
[](unsigned char c) { return (char)std::tolower(c); });
return s;
}
// "a/b/./c/../d" -> "a/b/d"(不越过根;越根的 ".." 直接丢)
std::string collapse(const std::string& in) {
std::vector<std::string> parts;
std::string cur;
auto flush = [&] {
if (cur.empty()) return;
if (cur == ".") {
} else if (cur == "..") {
if (!parts.empty()) parts.pop_back();
} else {
parts.push_back(cur);
}
cur.clear();
};
for (char c : in) {
if (c == '/') flush();
else cur += c;
}
flush();
std::string out;
for (size_t i = 0; i < parts.size(); ++i) {
if (i) out += '/';
out += parts[i];
}
return out;
}
const char* kYmir = "ymir work/";
// 若 path 含 "ymir work/",返回从那里起的后缀,否则 ""。
std::string ymir_suffix(const std::string& norm) {
auto p = norm.find(kYmir);
if (p == std::string::npos) return "";
return norm.substr(p);
}
// 一级目录归类:0=基础(在 priority 里),1=普通,2=patch/补丁
int pack_rank(const std::string& top, const std::vector<std::string>& prio) {
std::string t = lower(top);
for (size_t i = 0; i < prio.size(); ++i)
if (lower(prio[i]) == t) return (int)i; // 0..N-1
if (t.rfind("metin2_patch_", 0) == 0 || t.rfind("patch", 0) == 0) return 100000;
return 50000;
}
} // namespace
std::vector<std::string> AssetResolver::default_priority() {
return {"Zone", "Terrain", "ETC", "PC", "Tree", "Property", "Effect",
"Monster", "NPC", "season2", "season3_eu"};
}
std::string AssetResolver::normalize(const std::string& vp) {
std::string s = vp;
for (char& c : s)
if (c == '\\') c = '/';
s = lower(s);
// 去盘符 "d:/..." / "d:..."
if (s.size() >= 2 && std::isalpha((unsigned char)s[0]) && s[1] == ':')
s = s.substr(2);
while (!s.empty() && s[0] == '/') s.erase(s.begin());
return collapse(s);
}
bool AssetResolver::build(const std::string& root, const std::vector<std::string>& prio,
std::string* err) {
assets_root = root;
std::error_code ec;
if (!fs::exists(root, ec)) {
if (err) *err = "assets 根不存在: " + root;
return false;
}
// 收集 (rank, real_path, rel_norm) 后按 rank 稳定排序 —— 保证先注册者 = 高优先级
struct Rec {
int rank;
std::string real, rel;
};
std::vector<Rec> recs;
fs::path base(root);
for (auto it = fs::recursive_directory_iterator(
root, fs::directory_options::skip_permission_denied, ec);
it != fs::recursive_directory_iterator(); it.increment(ec)) {
if (ec) { ec.clear(); continue; }
if (!it->is_regular_file(ec)) continue;
std::string rel_real = fs::relative(it->path(), base, ec).generic_string();
std::string rel = lower(rel_real);
if (rel.empty()) continue;
std::string top = rel.substr(0, rel.find('/'));
// real_path 存**相对** assets_root(保留原始大小写);resolve() 拼回。
recs.push_back({pack_rank(top, prio), rel_real, rel});
}
std::stable_sort(recs.begin(), recs.end(),
[](const Rec& a, const Rec& b) { return a.rank < b.rank; });
std::vector<std::string> seen_conflicts;
for (auto& r : recs) {
++files_indexed;
// by_rel
{
auto& e = by_rel[r.rel];
if (e.real_path.empty()) e.real_path = r.real;
e.all_candidates.push_back(r.real);
}
// by_ymir
std::string suf = ymir_suffix(r.rel);
if (!suf.empty()) {
auto& e = by_ymir[suf];
if (e.real_path.empty()) {
e.real_path = r.real;
} else {
++ymir_conflicts;
if (seen_conflicts.size() < 50 &&
std::find(seen_conflicts.begin(), seen_conflicts.end(), suf) ==
seen_conflicts.end()) {
seen_conflicts.push_back(suf);
conflict_keys.push_back(suf);
}
}
e.all_candidates.push_back(r.real);
}
}
return true;
}
std::string AssetResolver::resolve(const std::string& vp, std::string* reason) const {
// The 40250 pack manager already resolves its own virtual paths and patch priority.
// Keep the drive prefix: CEterPackManager indexes "d:/ymir work/..." as such.
if (assets_root == "pack://") {
std::string name = lower(vp);
std::replace(name.begin(), name.end(), '\\', '/');
if (name.rfind("pack://", 0) == 0) name.erase(0, 7);
if (name.empty()) {
if (reason) *reason = "empty pack path";
return "";
}
return assets_root + name;
}
auto join = [this](const std::string& rel) {
if (rel.empty()) return std::string();
if (assets_root.empty()) return rel;
std::string r = assets_root;
if (r.back() != '/') r += '/';
return r + rel;
};
std::string norm = normalize(vp);
std::string suf = ymir_suffix(norm);
if (!suf.empty()) {
auto it = by_ymir.find(suf);
if (it != by_ymir.end()) return join(it->second.real_path);
if (reason) *reason = "未在 ymir 索引找到: " + suf;
return "";
}
auto it = by_rel.find(norm);
if (it != by_rel.end()) return join(it->second.real_path);
if (reason) *reason = "未在相对路径索引找到: " + norm;
return "";
}
// --- 索引烘焙 / 装载 ---------------------------------------------------
std::string AssetResolver::save_index() const {
// 行式:MTIDX1 头 + 每行 "Y|R\t<key>\t<rel>"。只存被采用的 real_path。
std::ostringstream ss;
ss << "MTIDX1\n";
for (const auto& kv : by_ymir) {
ss << "Y\t" << kv.first << '\t' << kv.second.real_path << '\n';
}
for (const auto& kv : by_rel) {
ss << "R\t" << kv.first << '\t' << kv.second.real_path << '\n';
}
return ss.str();
}
bool AssetResolver::load_index(const std::string& root, const std::string& text,
std::string* err) {
by_ymir.clear();
by_rel.clear();
files_indexed = 0;
ymir_conflicts = 0;
conflict_keys.clear();
assets_root = root;
std::istringstream in(text);
std::string line;
if (!std::getline(in, line) || line.rfind("MTIDX1", 0) != 0) {
if (err) *err = "asset_index:坏头(期望 MTIDX1)";
return false;
}
while (std::getline(in, line)) {
if (line.empty() || line[0] == '#') continue;
// tag \t key \t rel
size_t t1 = line.find('\t');
size_t t2 = (t1 == std::string::npos) ? std::string::npos : line.find('\t', t1 + 1);
if (t2 == std::string::npos) continue;
std::string tag = line.substr(0, t1);
std::string key = line.substr(t1 + 1, t2 - t1 - 1);
std::string rel = line.substr(t2 + 1);
if (!rel.empty() && rel.back() == '\r') rel.pop_back();
if (key.empty() || rel.empty()) continue;
Entry e;
e.real_path = rel;
if (tag == "Y") {
by_ymir.emplace(std::move(key), std::move(e));
} else if (tag == "R") {
by_rel.emplace(std::move(key), std::move(e));
}
++files_indexed;
}
if (by_ymir.empty() && by_rel.empty()) {
if (err) *err = "asset_index:空索引";
return false;
}
return true;
}
bool AssetResolver::build_or_load(const std::string& root,
const std::vector<std::string>& prio, std::string* err) {
if (root == "pack://") {
assets_root = root;
return true;
}
std::string text;
if (read_file(root + "/asset_index.txt", text) && text.rfind("MTIDX1", 0) == 0) {
return load_index(root, text, err);
}
return build(root, prio, err);
}
bool AssetResolver::exists(const std::string& vp) const {
return !resolve(vp, nullptr).empty();
}
std::vector<std::string> AssetResolver::all_rel() const {
std::vector<std::string> out;
out.reserve(by_rel.size());
for (const auto& kv : by_rel) out.push_back(kv.second.real_path);
return out;
}
} // namespace fmt
-64
View File
@@ -1,64 +0,0 @@
// AssetResolver —— 虚拟路径(`d:\ymir work\...`)-> 真实散文件路径。
// SHINSOO §4.2 / §9-W0 / BACKLOG G7。无 godot 依赖(放 formats/ 以便 CTest;
// SHINSOO §7.1 原列在 extension/src,此处按「解析层不依赖 godot」的原则前移)。
//
// 散文件按原 eterpack 名散在多个一级目录(Zone/ Terrain/ ETC/ PC/ Tree/ Property/
// metin2_patch_*/ ...),每个下面有 `ymir work/`。所以按「ymir work/ 之后的后缀」建索引,
// 而不是假设 `assets/ymir work/...`。
#pragma once
#include <map>
#include <string>
#include <vector>
namespace fmt {
struct AssetResolver {
struct Entry {
std::string real_path; // 采用的真实路径,**相对 assets_root**(resolve 时拼回)
std::vector<std::string> all_candidates; // 全部命中(开发模式查 patch 覆盖;load_index 后为空)
};
// key = 规范化后的索引键(小写 / '/')。两套:
// by_ymir : "ymir work/..." 之后的后缀 (虚拟路径主用)
// by_rel : assets 根起的相对路径 (地图数据等直接相对寻址)
std::map<std::string, Entry> by_ymir;
std::map<std::string, Entry> by_rel;
int files_indexed = 0;
int ymir_conflicts = 0; // 同 key 多个真实文件
std::vector<std::string> conflict_keys; // 去重后的冲突 key(前若干个)
std::string assets_root;
// pack 优先级:越靠前越优先(先注册者胜)。默认基础包在前、patch 包在后。
// 未列出的一级目录按字母序排在「基础」与「patch」之间。
bool build(const std::string& assets_root,
const std::vector<std::string>& pack_priority = default_priority(),
std::string* err = nullptr);
// 烘焙 / 装载索引(PCK 里没法 std::filesystem 扫目录)。
// save_index() -> 一段文本(行式,路径相对 assets_root)
// load_index() <- 该文本 + 运行时 assets_root(resolve 拼回真实路径)
std::string save_index() const;
bool load_index(const std::string& assets_root, const std::string& text,
std::string* err = nullptr);
// assets_root 下有 asset_index.txt 就 load,否则 build() 扫盘。
// 读文件走 fmt::read_file(host 可注入 FileAccess -> res:// 可用)。
bool build_or_load(const std::string& assets_root,
const std::vector<std::string>& pack_priority = default_priority(),
std::string* err = nullptr);
// 规范化:'\'->'/',去盘符,去开头 '/',小写,折叠 '//',处理 '.'/'..'。
static std::string normalize(const std::string& virtual_path);
// 解析。成功返回真实路径;失败返回 "" 且(可选)写 reason。
std::string resolve(const std::string& virtual_path, std::string* reason = nullptr) const;
bool exists(const std::string& virtual_path) const;
// 所有已索引文件的相对路径(by_rel 的 real_path 去重)。PropertyRegistry::scan_list 用。
std::vector<std::string> all_rel() const;
static std::vector<std::string> default_priority();
};
} // namespace fmt
-165
View File
@@ -1,165 +0,0 @@
#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
@@ -1,56 +0,0 @@
// .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
-186
View File
@@ -1,186 +0,0 @@
// M2 §3.5 —— playersettingmodule.py 的 ComboAttackNew 调用 -> PC 连击段表。
#include "combo_table.h"
#include "m2_tokvec.h" // fmt::read_file
#include <cctype>
#include <cstdlib>
namespace fmt {
const std::vector<uint16_t>* ComboTable::get(uint16_t motion_mode, uint16_t combo_type) const {
auto it = combos.find(make_combo_key(motion_mode, combo_type));
return it == combos.end() ? nullptr : &it->second;
}
const ComboTable& PlayerComboTables::klass(int combo_class) const {
static const ComboTable kEmpty;
if (combo_class < 0 || combo_class >= COMBO_CLASS_COUNT) return kEmpty;
return per_class[combo_class];
}
namespace {
std::string trim(const std::string& s) {
size_t a = 0, b = s.size();
while (a < b && std::isspace(static_cast<unsigned char>(s[a]))) ++a;
while (b > a && std::isspace(static_cast<unsigned char>(s[b - 1]))) --b;
return s.substr(a, b - a);
}
bool starts_with(const std::string& s, const char* p) {
return s.rfind(p, 0) == 0;
}
// chr.MOTION_MODE_XXX -> CRaceMotionData::EMode 值。
bool resolve_motion_mode(const std::string& suffix, long& out) {
static const std::map<std::string, long> kMap = {
{"RESERVED", 0}, {"GENERAL", 1}, {"ONEHAND_SWORD", 2}, {"TWOHAND_SWORD", 3},
{"DUALHAND_SWORD", 4}, {"BOW", 5}, {"FAN", 6}, {"BELL", 7}, {"FISHING", 8},
{"HORSE", 9}, {"HORSE_ONEHAND_SWORD", 10}, {"HORSE_TWOHAND_SWORD", 11},
{"HORSE_DUALHAND_SWORD", 12}, {"HORSE_BOW", 13}, {"HORSE_FAN", 14},
{"HORSE_BELL", 15}, {"WEDDING_DRESS", 16},
};
auto it = kMap.find(suffix);
if (it == kMap.end()) return false;
out = it->second;
return true;
}
// COMBO_TYPE_N / COMBO_INDEX_N -> N-1(两者都是从 1 起的人读编号,值从 0 起)。
bool resolve_one_based(const std::string& tail, long& out) {
if (tail.empty()) return false;
char* end = nullptr;
long n = std::strtol(tail.c_str(), &end, 10);
if (end == tail.c_str() || *end != '\0' || n < 1) return false;
out = n - 1;
return true;
}
// 一个实参 token -> 整数。认符号常量,也认裸整数。
bool resolve_token(const std::string& tok_in, long& out) {
const std::string tok = trim(tok_in);
if (tok.empty()) return false;
if (starts_with(tok, "chr.MOTION_MODE_")) {
return resolve_motion_mode(tok.substr(sizeof("chr.MOTION_MODE_") - 1), out);
}
if (starts_with(tok, "chr.MOTION_COMBO_ATTACK_")) {
if (!resolve_one_based(tok.substr(sizeof("chr.MOTION_COMBO_ATTACK_") - 1), out)) return false;
out = COMBO_NAME_COMBO_ATTACK_1 + out; // 0-based n -> 段号 14 + n
return true;
}
if (tok == "chr.MOTION_NORMAL_ATTACK") { out = COMBO_NAME_NORMAL_ATTACK; return true; }
if (starts_with(tok, "COMBO_TYPE_")) { return resolve_one_based(tok.substr(sizeof("COMBO_TYPE_") - 1), out); }
if (starts_with(tok, "COMBO_INDEX_")) { return resolve_one_based(tok.substr(sizeof("COMBO_INDEX_") - 1), out); }
// 裸整数
char* end = nullptr;
long n = std::strtol(tok.c_str(), &end, 10);
if (end == tok.c_str() || *end != '\0') return false;
out = n;
return true;
}
// "a, b, c)"(call 名后的部分)-> 解析出的整数实参。分号里没有嵌套括号。
bool parse_call_args(const std::string& after_paren, std::vector<long>& out) {
size_t close = after_paren.find(')');
if (close == std::string::npos) return false;
const std::string inner = after_paren.substr(0, close);
out.clear();
size_t start = 0;
while (start <= inner.size()) {
size_t comma = inner.find(',', start);
std::string tok = inner.substr(start, comma == std::string::npos ? std::string::npos : comma - start);
tok = trim(tok);
if (!tok.empty()) {
long v = 0;
if (!resolve_token(tok, v)) return false;
out.push_back(v);
}
if (comma == std::string::npos) break;
start = comma + 1;
}
return true;
}
int class_from_def_line(const std::string& line) {
if (line.find("def __LoadGameWarriorEx") != std::string::npos) return COMBO_CLASS_WARRIOR;
if (line.find("def __LoadGameAssassinEx") != std::string::npos) return COMBO_CLASS_ASSASSIN;
if (line.find("def __LoadGameSuraEx") != std::string::npos) return COMBO_CLASS_SURA;
if (line.find("def __LoadGameShamanEx") != std::string::npos) return COMBO_CLASS_SHAMAN;
return -1;
}
} // namespace
bool parse_player_combo_tables(const std::string& py_text, PlayerComboTables& out, std::string* err) {
out = PlayerComboTables{};
int cur_class = -1;
size_t pos = 0;
const size_t n = py_text.size();
while (pos < n) {
size_t eol = py_text.find('\n', pos);
std::string line = py_text.substr(pos, eol == std::string::npos ? std::string::npos : eol - pos);
pos = (eol == std::string::npos) ? n : eol + 1;
const std::string t = trim(line);
if (t.empty() || t[0] == '#') continue;
// 顶层 def 换块:只有四个 __LoadGame<Class>Ex 有连击段调用,其它 def 复位。
if (starts_with(line, "def ")) {
cur_class = class_from_def_line(line);
continue;
}
if (cur_class < 0) continue;
const char* kReserve = "chrmgr.ReserveComboAttackNew(";
const char* kRegister = "chrmgr.RegisterComboAttackNew(";
size_t r = t.find(kReserve);
if (r != std::string::npos) {
std::vector<long> args;
if (!parse_call_args(t.substr(r + std::string(kReserve).size()), args) || args.size() != 3) {
if (err) *err = "combo_table: 无法解析 ReserveComboAttackNew: " + t;
return false;
}
const uint32_t key = make_combo_key(static_cast<uint16_t>(args[0]), static_cast<uint16_t>(args[1]));
const long count = args[2] < 0 ? 0 : args[2];
// CRaceData::ReserveComboAttack 用 map::insert —— 已存在的 key 不覆盖。
out.per_class[cur_class].combos.emplace(
key, std::vector<uint16_t>(static_cast<size_t>(count), 0));
continue;
}
size_t g = t.find(kRegister);
if (g != std::string::npos) {
std::vector<long> args;
if (!parse_call_args(t.substr(g + std::string(kRegister).size()), args) || args.size() != 4) {
if (err) *err = "combo_table: 无法解析 RegisterComboAttackNew: " + t;
return false;
}
const uint32_t key = make_combo_key(static_cast<uint16_t>(args[0]), static_cast<uint16_t>(args[1]));
const long idx = args[2];
const uint16_t motion = static_cast<uint16_t>(args[3]);
auto it = out.per_class[cur_class].combos.find(key);
if (it == out.per_class[cur_class].combos.end()) continue; // 未 Reserve:跳过
if (idx < 0 || static_cast<size_t>(idx) >= it->second.size()) continue; // 越界:跳过
it->second[static_cast<size_t>(idx)] = motion;
continue;
}
}
if (err) err->clear();
return true;
}
bool parse_player_combo_tables_file(const std::string& path, PlayerComboTables& out, std::string* err) {
std::string s;
if (!read_file(path, s)) {
if (err) *err = "combo_table: 打不开 " + path;
return false;
}
return parse_player_combo_tables(s, out, err);
}
} // namespace fmt
-92
View File
@@ -1,92 +0,0 @@
// combo_table —— PC 连击段表(CRaceData::TComboAttackDataMap 的等价)。M2 §3.5。
//
// 参考端的 PC 连击段表**不在** .msm 里(.msm 只有 BaseModelFileName + HairData)。
// 它由 playersettingmodule.py 的 chrmgr.ReserveComboAttackNew / RegisterComboAttackNew
// 调用在 CRaceData 上建起来(UserInterface/PythonCharacterManagerModule.cpp →
// CRaceData::ReserveComboAttack / RegisterComboAttack,GameLib/RaceData.cpp)。
// 本解析器就把那批调用读成同一张表。
//
// NPC/怪物的连击段走另一条路(RaceManager.cpp __LoadRaceMotionList 读 motlist.txt,
// 只在 MODE_GENERAL 下登记 NAME_COMBO_ATTACK_1/2/3,没有段号向量),不在这里。
#pragma once
#include <cstdint>
#include <map>
#include <string>
#include <vector>
namespace fmt {
// CRaceMotionData::EMode(chr.MOTION_MODE_*)。combo key 的高 16 位。
enum ComboMotionMode : uint16_t {
COMBO_MODE_RESERVED = 0,
COMBO_MODE_GENERAL = 1,
COMBO_MODE_ONEHAND_SWORD = 2,
COMBO_MODE_TWOHAND_SWORD = 3,
COMBO_MODE_DUALHAND_SWORD = 4,
COMBO_MODE_BOW = 5,
COMBO_MODE_FAN = 6,
COMBO_MODE_BELL = 7,
COMBO_MODE_FISHING = 8,
COMBO_MODE_HORSE = 9,
COMBO_MODE_HORSE_ONEHAND_SWORD = 10,
COMBO_MODE_HORSE_TWOHAND_SWORD = 11,
COMBO_MODE_HORSE_DUALHAND_SWORD = 12,
COMBO_MODE_HORSE_BOW = 13,
COMBO_MODE_HORSE_FAN = 14,
COMBO_MODE_HORSE_BELL = 15,
COMBO_MODE_WEDDING_DRESS = 16,
};
// CRaceMotionData::EName 的攻击段(chr.MOTION_NORMAL_ATTACK / chr.MOTION_COMBO_ATTACK_*)。
// ComboIndexVector 里存的就是这些段号。
enum : uint16_t {
COMBO_NAME_NORMAL_ATTACK = 13,
COMBO_NAME_COMBO_ATTACK_1 = 14,
COMBO_NAME_COMBO_ATTACK_8 = 21,
};
// RaceData.h: MAKE_COMBO_KEY(motion_mode, combo_type)
inline uint32_t make_combo_key(uint16_t motion_mode, uint16_t combo_type) {
return (static_cast<uint32_t>(motion_mode) << 16) | static_cast<uint32_t>(combo_type);
}
inline uint16_t combo_key_mode(uint32_t key) { return static_cast<uint16_t>((key >> 16) & 0xFFFF); }
inline uint16_t combo_key_type(uint32_t key) { return static_cast<uint16_t>(key & 0xFFFF); }
// playersettingmodule.py 按 __LoadGame<Class>Ex(race, path) 分四块,男女同一函数体
// (连击段表与性别无关),所以这里按职业收,不按 race。
enum ComboClass : int {
COMBO_CLASS_WARRIOR = 0,
COMBO_CLASS_ASSASSIN = 1,
COMBO_CLASS_SURA = 2,
COMBO_CLASS_SHAMAN = 3,
COMBO_CLASS_COUNT = 4,
};
// CRaceData::TComboAttackDataMap 的等价:MAKE_COMBO_KEY -> ComboIndexVector。
struct ComboTable {
// key = make_combo_key(mode, type);value = 有序的 COMBO_NAME_COMBO_ATTACK_* 段号,
// 下标即 dwComboArrayIndex(m_dwcurComboIndex - 1)。
std::map<uint32_t, std::vector<uint16_t>> combos;
// GetComboDataPointer:命中返回段号向量指针,否则 nullptr。
const std::vector<uint16_t>* get(uint16_t motion_mode, uint16_t combo_type) const;
bool empty() const { return combos.empty(); }
size_t key_count() const { return combos.size(); }
};
struct PlayerComboTables {
ComboTable per_class[COMBO_CLASS_COUNT];
// 越界返回一张空表(不抛)。
const ComboTable& klass(int combo_class) const;
};
// 解析 playersettingmodule.py 里的 chrmgr.ReserveComboAttackNew / RegisterComboAttackNew
// 调用,按 def __LoadGame<Class>Ex 分块填 per_class。照 CRaceData 语义:
// Reserve -> combos[key] = vector(count, 0)(重复 key 不覆盖,同 std::map::insert)
// Register -> combos[key][index] = motion(key 未 Reserve 或 index 越界则跳过)
// 认得的符号:chr.MOTION_MODE_*、chr.MOTION_COMBO_ATTACK_N(=13+N)、
// chr.MOTION_NORMAL_ATTACK(=13)、COMBO_TYPE_N(=N-1)、COMBO_INDEX_N(=N-1)、裸整数。
bool parse_player_combo_tables(const std::string& py_text, PlayerComboTables& out, std::string* err);
bool parse_player_combo_tables_file(const std::string& path, PlayerComboTables& out, std::string* err);
} // namespace fmt
-218
View File
@@ -1,218 +0,0 @@
#include "environment.h"
#include "m2_tokvec.h" // read_file
#include <cctype>
#include <cstdlib>
#include <cstring>
#include <sstream>
namespace fmt {
namespace {
bool ieq(const std::string& a, const char* b) {
if (a.size() != std::strlen(b)) return false;
for (size_t i = 0; i < a.size(); ++i)
if (std::tolower((unsigned char)a[i]) != std::tolower((unsigned char)b[i])) return false;
return true;
}
std::vector<std::string> tok_line(const std::string& line) {
std::vector<std::string> out;
size_t i = 0, n = line.size();
while (i < n) {
char c = line[i];
if (c == '#' || (c == '/' && i + 1 < n && line[i + 1] == '/')) break;
if (std::isspace((unsigned char)c)) { ++i; continue; }
if (c == '{' || c == '}') { out.emplace_back(1, c); ++i; continue; }
if (c == '"') {
++i;
std::string s;
while (i < n && line[i] != '"') s += line[i++];
if (i < n) ++i;
out.push_back(s);
continue;
}
std::string s;
while (i < n && !std::isspace((unsigned char)line[i]) && line[i] != '{' && line[i] != '}')
s += line[i++];
out.push_back(s);
}
return out;
}
struct Block {
std::string name;
bool is_list = false;
std::vector<std::vector<std::string>> lines; // kv lines (lines[k][0] = key)
std::vector<std::vector<float>> rows; // List rows
std::vector<Block> children;
const std::vector<std::string>* kv(const char* key) const {
for (auto& l : lines)
if (!l.empty() && ieq(l[0], key)) return &l;
return nullptr;
}
const Block* child(const char* nm) const {
for (auto& c : children)
if (ieq(c.name, nm)) return &c;
return nullptr;
}
float f(const char* key, int idx, float def = 0) const {
auto* l = kv(key);
return (l && (int)l->size() > idx) ? (float)std::atof((*l)[idx].c_str()) : def;
}
int i(const char* key, int def = 0) const {
auto* l = kv(key);
return (l && l->size() > 1) ? std::atoi((*l)[1].c_str()) : def;
}
std::string s(const char* key, const std::string& def = "") const {
auto* l = kv(key);
return (l && l->size() > 1) ? (*l)[1] : def;
}
};
bool parse_tree(const std::string& text, Block& root, std::string* err) {
std::istringstream in(text);
std::string line;
std::vector<Block*> stack{&root};
std::string pending; // name of a Group/List awaiting '{'
bool pending_list = false;
while (std::getline(in, line)) {
if (!line.empty() && line.back() == '\r') line.pop_back();
auto t = tok_line(line);
for (size_t k = 0; k < t.size(); ++k) {
const std::string& x = t[k];
if (x == "{") {
Block b;
b.name = pending;
b.is_list = pending_list;
stack.back()->children.push_back(std::move(b));
stack.push_back(&stack.back()->children.back());
pending.clear();
pending_list = false;
} else if (x == "}") {
if (stack.size() <= 1) { if (err) *err = "msenv: 多余的 }"; return false; }
stack.pop_back();
} else if (ieq(x, "Group") || ieq(x, "List")) {
pending_list = ieq(x, "List");
pending = (k + 1 < t.size()) ? t[k + 1] : "";
++k;
} else {
// 一行剩余 token 作为一条记录
std::vector<std::string> rec(t.begin() + k, t.end());
if (stack.back()->is_list) {
std::vector<float> row;
for (auto& v : rec) row.push_back((float)std::atof(v.c_str()));
stack.back()->rows.push_back(std::move(row));
} else {
stack.back()->lines.push_back(std::move(rec));
}
break; // 行内其余 token 已并入本记录
}
}
}
if (stack.size() != 1) { if (err) *err = "msenv: 块未闭合"; return false; }
return true;
}
Rgba rgba(const std::vector<std::string>* l, int off = 1) {
Rgba c{{0, 0, 0, 1}};
if (l)
for (int k = 0; k < 4 && off + k < (int)l->size(); ++k)
c[k] = (float)std::atof((*l)[off + k].c_str());
return c;
}
Rgba row_rgba(const std::vector<float>& r) {
Rgba c{{0, 0, 0, 0}};
for (int k = 0; k < 4 && k < (int)r.size(); ++k) c[k] = r[k];
return c;
}
} // namespace
bool parse_environment(const std::string& text, Environment& out, std::string* err) {
Block root;
if (!parse_tree(text, root, err)) return false;
out = Environment{};
if (auto* l = root.kv("ScriptType")) out.script_type = l->size() > 1 ? (*l)[1] : "";
out.script_version = root.f("ScriptVersion", 1);
out.reserved = root.i("Reserved") != 0;
if (const Block* dl = root.child("DirectionalLight")) {
if (auto* d = dl->kv("Direction"))
for (int k = 0; k < 3 && k + 1 < (int)d->size(); ++k)
out.dir_light.direction[k] = (float)std::atof((*d)[k + 1].c_str());
if (const Block* bg = dl->child("Background")) {
out.dir_light.bg_enable = bg->i("Enable") != 0;
out.dir_light.bg_diffuse = rgba(bg->kv("Diffuse"));
out.dir_light.bg_ambient = rgba(bg->kv("Ambient"));
}
if (const Block* ch = dl->child("Character")) {
out.dir_light.ch_enable = ch->i("Enable") != 0;
out.dir_light.ch_diffuse = rgba(ch->kv("Diffuse"));
out.dir_light.ch_ambient = rgba(ch->kv("Ambient"));
}
}
if (const Block* m = root.child("Material")) {
out.material.diffuse = rgba(m->kv("Diffuse"));
out.material.ambient = rgba(m->kv("Ambient"));
out.material.emissive = rgba(m->kv("Emissive"));
}
if (const Block* fg = root.child("Fog")) {
out.fog.fog_level = fg->i("foglevel", 0);
out.fog.density_fog = fg->i("IsDensity") != 0;
out.fog.near_distance = fg->f("NearDistance", 1);
out.fog.far_distance = fg->f("FarDistance", 1);
out.fog.color = rgba(fg->kv("Color"));
out.fog.enable = (fg->kv("Enable") && fg->i("Enable") != 0) || out.fog.fog_level > 0;
}
if (const Block* flt = root.child("Filter")) {
out.filter.enable = flt->i("Enable") != 0;
out.filter.color = rgba(flt->kv("Color"));
out.filter.alpha_src = flt->i("AlphaSrc", out.filter.alpha_src);
out.filter.alpha_dest = flt->i("AlphaDest", out.filter.alpha_dest);
}
if (const Block* sb = root.child("SkyBox")) {
out.sky.texture_render_mode = sb->i("BTextureRenderMode") != 0;
for (int k = 0; k < 3; ++k) out.sky.scale[k] = sb->f("Scale", k + 1);
out.sky.gradient_level_upper = sb->i("GradientLevelUpper");
out.sky.gradient_level_lower = sb->i("GradientLevelLower");
static constexpr const char* kFaceKeys[6] = {
"FrontFaceFileName", "BackFaceFileName", "LeftFaceFileName",
"RightFaceFileName", "TopFaceFileName", "BottomFaceFileName"};
for (int k = 0; k < 6; ++k)
out.sky.face_textures[k] = sb->s(kFaceKeys[k]);
for (int k = 0; k < 2; ++k) {
out.sky.cloud_scale[k] = sb->f("CloudScale", k + 1);
out.sky.cloud_texture_scale[k] = sb->f("CloudTextureScale", k + 1);
out.sky.cloud_speed[k] = sb->f("CloudSpeed", k + 1);
}
out.sky.cloud_height = sb->f("CloudHeight", 1);
out.sky.cloud_texture = sb->s("CloudTextureFileName");
if (const Block* cc = sb->child("CloudColor"))
for (auto& r : cc->rows) out.sky.cloud_color.push_back(row_rgba(r));
if (const Block* gr = sb->child("Gradient"))
for (auto& r : gr->rows) out.sky.gradient.push_back(row_rgba(r));
}
if (const Block* lf = root.child("LensFlare")) {
out.lens_flare.enable = lf->i("Enable") != 0;
out.lens_flare.brightness_color = rgba(lf->kv("BrightnessColor"));
out.lens_flare.max_brightness = lf->f("MaxBrightness", 1);
out.lens_flare.main_flare_enable = lf->i("MainFlareEnable") != 0;
out.lens_flare.main_flare_texture = lf->s("MainFlareTextureFileName");
out.lens_flare.main_flare_size = lf->f("MainFlareSize", 1);
}
return true;
}
bool parse_environment_file(const std::string& path, Environment& out, std::string* err) {
std::string text;
if (!read_file(path, text)) { if (err) *err = "打不开 " + path; return false; }
return parse_environment(text, out, err);
}
} // namespace fmt
-72
View File
@@ -1,72 +0,0 @@
// .msenv(首行 `ScriptType EnvrionmentData` —— 原格式拼写错误,照收)。
// 文本,`Group NAME { ... }` + `List NAME { ... }`(匿名 rgba 行)。SHINSOO §5.5。
// 真值:ETC/ymir work/environment/*.msenv 实测 + EterLib 环境类。W5 用。
#pragma once
#include <array>
#include <string>
#include <vector>
namespace fmt {
using Rgba = std::array<float, 4>;
using Vec3f = std::array<float, 3>;
using Vec2f = std::array<float, 2>;
struct EnvDirLight {
Vec3f direction{{0.5f, 0.5f, -0.5f}};
bool bg_enable = false, ch_enable = false;
Rgba bg_diffuse{{1, 1, 1, 1}}, bg_ambient{{0.5f, 0.5f, 0.5f, 1}};
Rgba ch_diffuse{{1, 1, 1, 1}}, ch_ambient{{0.5f, 0.5f, 0.5f, 1}};
};
struct EnvMaterial {
Rgba diffuse{{0.8f, 0.8f, 0.8f, 1}}, ambient{{0.8f, 0.8f, 0.8f, 1}},
emissive{{0.8f, 0.8f, 0.8f, 1}};
};
struct EnvFog {
// 两种写法:老版 `Enable`/`NearDistance`/`FarDistance`;A1 实盘用 `foglevel`(0=关,>0=开)。
bool enable = false; // Enable != 0 || fog_level > 0
bool density_fog = false; // IsDensity(参考端保留,当前 Godot 用深度雾)
int fog_level = 0; // foglevel
float near_distance = 25600.0f * 0.5f, far_distance = 25600.0f * 0.7f;
Rgba color{{0.5f, 0.5f, 0.5f, 1}};
};
struct EnvFilter {
bool enable = false;
Rgba color{{0.3f, 0.1f, 0.1f, 0}};
int alpha_src = 1; // D3DBLEND_ONE
int alpha_dest = 1; // D3DBLEND_ONE
};
struct EnvSkyBox {
Vec3f scale{{3500, 3500, 3500}};
bool texture_render_mode = false;
int gradient_level_upper = 0, gradient_level_lower = 0;
std::array<std::string, 6> face_textures{}; // front/back/left/right/top/bottom
Vec2f cloud_scale{{200000, 200000}}, cloud_texture_scale{{4, 4}}, cloud_speed{{0.001f, 0.001f}};
float cloud_height = 30000;
std::string cloud_texture;
std::vector<Rgba> cloud_color; // List CloudColor
std::vector<Rgba> gradient; // List Gradient(成对 = 一段渐变的上下色)
};
struct EnvLensFlare {
bool enable = false, main_flare_enable = false;
Rgba brightness_color{{1, 1, 1, 1}};
float max_brightness = 1, main_flare_size = 0.2f;
std::string main_flare_texture;
};
struct Environment {
std::string script_type; // "EnvrionmentData"
float script_version = 0;
EnvDirLight dir_light;
EnvMaterial material;
EnvFog fog;
EnvFilter filter;
EnvSkyBox sky;
EnvLensFlare lens_flare;
bool reserved = false;
};
bool parse_environment(const std::string& text, Environment& out, std::string* err);
bool parse_environment_file(const std::string& path, Environment& out, std::string* err);
} // namespace fmt
-60
View File
@@ -1,60 +0,0 @@
#include "m2_coord.h"
#include <cmath>
#include <cstdio>
namespace fmt::m2coord {
std::string tile_dir(int tile_x, int tile_y) {
char buf[16];
std::snprintf(buf, sizeof(buf), "%06u", tile_id(tile_x, tile_y));
return buf;
}
namespace {
Mat3 mul(const Mat3& a, const Mat3& b) {
Mat3 r{};
for (int i = 0; i < 3; ++i)
for (int j = 0; j < 3; ++j)
for (int k = 0; k < 3; ++k)
r.m[i * 3 + j] += a.m[i * 3 + k] * b.m[k * 3 + j];
return r;
}
Mat3 rot_x(double a) {
double c = std::cos(a), s = std::sin(a);
return {{1, 0, 0, 0, c, -s, 0, s, c}};
}
Mat3 rot_y(double a) {
double c = std::cos(a), s = std::sin(a);
return {{c, 0, s, 0, 1, 0, -s, 0, c}};
}
Mat3 rot_z(double a) {
double c = std::cos(a), s = std::sin(a);
return {{c, -s, 0, s, c, 0, 0, 0, 1}};
}
} // namespace
Mat3 ypr_basis(double yaw_deg, double pitch_deg, double roll_deg) {
const double d2r = 3.14159265358979323846 / 180.0;
return mul(mul(rot_y(yaw_deg * d2r), rot_x(pitch_deg * d2r)), rot_z(roll_deg * d2r));
}
Mat3 object_basis_godot(double yaw_deg, double pitch_deg, double roll_deg) {
// Conjugate the Metin2-space rotation through the Z-up -> Y-up axis swap
// C = rotate(-90deg, X): (x,y,z) -> (x,z,-y). R_godot = C * R_m2 * C^-1
const Mat3 C{{1, 0, 0, 0, 0, 1, 0, -1, 0}};
const Mat3 Ci{{1, 0, 0, 0, 0, -1, 0, 1, 0}};
return mul(mul(C, ypr_basis(yaw_deg, pitch_deg, roll_deg)), Ci);
}
bool parse_tile_dir(const std::string& name, int& tile_x, int& tile_y) {
if (name.size() != 6) return false;
for (char c : name)
if (c < '0' || c > '9') return false;
long id = std::strtol(name.c_str(), nullptr, 10);
tile_x = int(id / 1000);
tile_y = int(id % 1000);
return true;
}
} // namespace fmt::m2coord
-66
View File
@@ -1,66 +0,0 @@
// Metin2 -> Godot 坐标 / 单位统一转换(纯数学,无 godot 依赖)。
// BACKLOG I5 · SHINSOO §6。extension 侧的 mtgodot::coord 薄封装应直接调用这里,
// 不得另写公式。
//
// Metin2:厘米,Z-up(左手) Godot:米,Y-up(右手)
// Godot.x = Metin2.x * 0.01
// Godot.y = Metin2.z * 0.01
// Godot.z = -Metin2.y * 0.01
// 等价于 make_conv 的 rotate(-90°,X) + scale(0.01)。
#pragma once
#include <cstdint>
#include <string>
namespace fmt::m2coord {
struct Vec3 { double x = 0, y = 0, z = 0; };
constexpr double CM_TO_M = 0.01;
constexpr int CELLSCALE = 200; // PRTerrainLib TerrainType.h
constexpr int TERRAIN_SIZE = 128;
constexpr int CHUNK_CM = CELLSCALE * TERRAIN_SIZE; // 25600 —— 一个区块的边长(cm)
// 位置:厘米 Z-up -> 米 Y-up。
inline Vec3 position_to_godot(double x_cm, double y_cm, double z_cm) {
return { x_cm * CM_TO_M, z_cm * CM_TO_M, -y_cm * CM_TO_M };
}
inline Vec3 position_to_godot(const Vec3& v) { return position_to_godot(v.x, v.y, v.z); }
// 方向:只做轴转换,不缩放、不平移。
inline Vec3 direction_to_godot(double x, double y, double z) {
return { x, z, -y };
}
// 区块目录编号:tile_id = tile_x*1000 + tile_y,目录名 = 6 位十进制。
inline uint32_t tile_id(int tile_x, int tile_y) {
return uint32_t(tile_x) * 1000u + uint32_t(tile_y);
}
std::string tile_dir(int tile_x, int tile_y); // "000000" / "001003"
bool parse_tile_dir(const std::string& name, int& tile_x, int& tile_y);
// 区块原点(该区块 (0,0) 格在地图内的厘米坐标,未做轴转换)。
inline Vec3 chunk_origin_cm(int tile_x, int tile_y) {
return { double(tile_x) * CHUNK_CM, double(tile_y) * CHUNK_CM, 0.0 };
}
// height.raw 采样值 -> 厘米高度:raw * HeightScale。
inline double height_raw_to_cm(uint16_t raw, double height_scale) {
return double(raw) * height_scale;
}
struct Mat3 { double m[9]; }; // row-major: m[r*3+c]
// AreaData 的 yaw#pitch#roll(度)-> **Metin2 Z-up 空间**的旋转(行主序 3×3)。
// 忠实移植原客户端 `CGraphicObjectInstance::SetRotation` 的
// `D3DXMatrixRotationYawPitchRoll(yaw, pitch, roll)`(列向量约定下 = Ry(yaw)·Rx(pitch)·Rz(roll))。
// 注意 Metin2 世界是 Z-up:yaw 绕 Y、pitch 绕 X、**roll 绕 Z = 竖直轴 = 朝向**。
// areadata 单值旋转 → roll(`Area.cpp:820`)。这一版**未**做 Z-up→Y-up 轴转换,
// 直接塞进 Godot Basis 会把「朝向」错当成绕 Godot Z 的翻滚 —— 放置物体请用 object_basis_godot()。
Mat3 ypr_basis(double yaw_deg, double pitch_deg, double roll_deg);
// 同上,但已把旋转共轭到 **Godot Y-up 空间**(R_godot = C · R_m2 · C⁻¹,C = rotate(-90°,X))。
// 可直接作为 Godot Basis 使用:物体的 Godot 变换 = Transform3D(object_basis_godot(...), g) * make_conv()。
// roll(朝向)→ 绕 Godot +Y 的 heading,不再翻滚。行主序 3×3。
Mat3 object_basis_godot(double yaw_deg, double pitch_deg, double roll_deg);
} // namespace fmt::m2coord
-155
View File
@@ -1,155 +0,0 @@
#include "m2_tokvec.h"
#include <algorithm>
#include <cctype>
#include <cstdlib>
#include <fstream>
#include <sstream>
namespace fmt {
namespace {
std::string lower(std::string s) {
std::transform(s.begin(), s.end(), s.begin(),
[](unsigned char c) { return (char)std::tolower(c); });
return s;
}
bool ieq(const std::string& a, const char* b) {
return lower(a) == b;
}
// 一行 -> token 列表。`"..."` 单 token;`#` / `//` 到行尾注释。
std::vector<std::string> tokenize_line(const std::string& line) {
std::vector<std::string> out;
size_t i = 0, n = line.size();
while (i < n) {
char c = line[i];
if (c == '#' || (c == '/' && i + 1 < n && line[i + 1] == '/')) break;
if (std::isspace((unsigned char)c)) { ++i; continue; }
if (c == '"') {
++i;
std::string s;
while (i < n && line[i] != '"') s += line[i++];
if (i < n) ++i; // 收尾引号
out.push_back(s);
continue;
}
std::string s;
while (i < n && !std::isspace((unsigned char)line[i]))
s += line[i++];
out.push_back(s);
}
return out;
}
} // namespace
bool TokVecMap::has(const std::string& key) const { return m.count(key) != 0; }
const std::vector<std::string>* TokVecMap::find(const std::string& key) const {
auto it = m.find(lower(key));
return it == m.end() ? nullptr : &it->second;
}
std::string TokVecMap::str(const std::string& key, const std::string& def) const {
auto* v = find(key);
return (v && !v->empty()) ? (*v)[0] : def;
}
long TokVecMap::inum(const std::string& key, long def) const {
auto* v = find(key);
return (v && !v->empty()) ? std::strtol((*v)[0].c_str(), nullptr, 10) : def;
}
double TokVecMap::num(const std::string& key, double def) const {
auto* v = find(key);
return (v && !v->empty()) ? std::strtod((*v)[0].c_str(), nullptr) : def;
}
bool parse_tokvec(const std::string& text, TokVecMap& out, std::string* err) {
out = TokVecMap{};
std::istringstream in(text);
std::string line;
std::string block_key; // 非空 = 正在收块
std::vector<std::string> block_tokens;
std::vector<std::string> seen_dups;
auto commit = [&](const std::string& key, std::vector<std::string> toks) {
std::string k = lower(key);
if (out.m.count(k)) {
out.dup_count++;
if (std::find(seen_dups.begin(), seen_dups.end(), k) == seen_dups.end()) {
seen_dups.push_back(k);
out.dup_keys.push_back(k);
}
return; // 保留第一个
}
out.m.emplace(std::move(k), std::move(toks));
};
while (std::getline(in, line)) {
if (!line.empty() && line.back() == '\r') line.pop_back();
auto toks = tokenize_line(line);
if (toks.empty()) continue;
if (!block_key.empty()) {
if (ieq(toks[0], "end")) {
commit(block_key, std::move(block_tokens));
block_key.clear();
block_tokens.clear();
} else {
for (auto& t : toks) block_tokens.push_back(std::move(t));
}
continue;
}
if (ieq(toks[0], "start")) {
if (toks.size() < 2) {
if (err) *err = "Start 缺块名";
return false;
}
block_key = toks[1];
block_tokens.clear();
continue;
}
// 顶层 `key v1 v2 ...`
std::string key = toks[0];
std::vector<std::string> vals(toks.begin() + 1, toks.end());
commit(key, std::move(vals));
}
if (!block_key.empty()) {
// 文件在块中截断 —— 宽容收尾但记 err
commit(block_key, std::move(block_tokens));
if (err) *err = "文件在 Start '" + block_key + "' 块中截断(缺 End)";
}
return true;
}
static FileReader g_file_reader = nullptr;
void set_file_reader(FileReader r) { g_file_reader = r; }
bool read_file(const std::string& path, std::string& out) {
if (g_file_reader) return g_file_reader(path, out);
std::ifstream f(path, std::ios::binary);
if (!f) return false;
std::ostringstream ss;
ss << f.rdbuf();
out = ss.str();
return true;
}
bool parse_tokvec_file(const std::string& path, TokVecMap& out, std::string* err) {
std::string text;
if (!read_file(path, text)) {
if (err) *err = "打不开文件: " + path;
return false;
}
return parse_tokvec(text, out, err);
}
} // namespace fmt
-43
View File
@@ -1,43 +0,0 @@
// Metin2 地图文本格式的通用解析 —— 对标原客户端 EterBase `LoadMultipleTextData` +
// `CTokenVectorMap`(`GameLib/Area.cpp`、`PRTerrainLib/TextureSet.cpp` 都用它)。
//
// 语义:
// - 以空白分隔 token;`"..."` 为单 token;`#` 或 `//` 到行尾是注释。
// - 顶层行 `key v1 v2 ...` -> map[lower(key)] = [v1, v2, ...]
// - `Start <Name>` ... `End [<Name>]` -> map[lower(Name)] = [块内所有 token 展平]
// - 重复 key:保留第一个,`dup_count` 记录冲突数(W0 报告用)。
//
// 无 godot 依赖,纯 C++20,走 CTest。SHINSOO §9-W0 / §5。
#pragma once
#include <map>
#include <string>
#include <vector>
namespace fmt {
struct TokVecMap {
std::map<std::string, std::vector<std::string>> m;
int dup_count = 0; // 重复 key 次数
std::vector<std::string> dup_keys; // 重复的 key 名(去重后)
bool has(const std::string& key) const; // key 已小写
const std::vector<std::string>* find(const std::string& key) const;
// 便捷取值(key 传入时大小写不敏感)
std::string str(const std::string& key, const std::string& def = "") const;
long inum(const std::string& key, long def = 0) const;
double num(const std::string& key, double def = 0) const;
};
// 解析整段文本。语法层面不会失败(宽容解析);err 仅在明显结构错误时写。
bool parse_tokvec(const std::string& text, TokVecMap& out, std::string* err);
bool parse_tokvec_file(const std::string& path, TokVecMap& out, std::string* err);
// 小工具:整文件读入字符串。失败返回 false。
// Whole-file read used by every fmt loader. By default std::ifstream; the host
// app can install a reader that pulls from res:// (Godot PCK on iOS/Android) via
// set_file_reader(). The standalone CTests leave it unset -> plain ifstream.
using FileReader = bool (*)(const std::string& path, std::string& out);
void set_file_reader(FileReader r); // nullptr resets to the ifstream default
bool read_file(const std::string& path, std::string& out);
} // namespace fmt
-60
View File
@@ -1,60 +0,0 @@
#include "map_setting.h"
#include "m2_tokvec.h"
#include <cstdlib>
namespace fmt {
bool parse_map_setting(const std::string& text, MapSetting& out, std::string* err) {
TokVecMap tv;
std::string perr;
parse_tokvec(text, tv, &perr);
out = MapSetting{};
if (!tv.has("mapsize")) {
if (err) *err = "setting.txt 缺 MapSize";
return false;
}
if (const auto* v = tv.find("mapsize"); v && v->size() >= 2) {
out.map_size_x = (int)std::strtol((*v)[0].c_str(), nullptr, 10);
out.map_size_y = (int)std::strtol((*v)[1].c_str(), nullptr, 10);
} else {
if (err) *err = "setting.txt MapSize 需要两个整数";
return false;
}
if (out.map_size_x <= 0 || out.map_size_y <= 0) {
if (err) *err = "setting.txt MapSize 非法";
return false;
}
if (tv.has("cellscale")) out.cell_scale = (int)tv.inum("cellscale");
if (tv.has("heightscale")) out.height_scale = tv.num("heightscale");
out.view_radius = (int)tv.inum("viewradius", 0);
if (const auto* v = tv.find("baseposition"); v && v->size() >= 2) {
out.base_position_x = std::strtol((*v)[0].c_str(), nullptr, 10);
out.base_position_y = std::strtol((*v)[1].c_str(), nullptr, 10);
}
out.texture_set = tv.str("textureset");
out.environment = tv.str("environment");
if (out.cell_scale <= 0) {
if (err) *err = "setting.txt CellScale 非法";
return false;
}
return true;
}
bool parse_map_setting_file(const std::string& path, MapSetting& out, std::string* err) {
std::string text;
if (!read_file(path, text)) {
if (err) *err = "打不开 " + path;
return false;
}
return parse_map_setting(text, out, err);
}
} // namespace fmt
-26
View File
@@ -1,26 +0,0 @@
// setting.txt(ScriptType MapSetting)—— 地图全局设置。
// 对标 GameLib/MapOutdoorLoad.cpp 的 setting 解析。SHINSOO §5.1。
#pragma once
#include <string>
namespace fmt {
struct MapSetting {
int cell_scale = 200; // CellScale(cm/格),默认取 PRTerrainLib 常量
double height_scale = 0.5; // HeightScale(raw 高度乘数)
int view_radius = 0; // ViewRadius
int map_size_x = 0; // MapSize 第 1 个数(区块列数)
int map_size_y = 0; // MapSize 第 2 个数(区块行数)
long base_position_x = 0; // BasePosition(全局世界基准,cm)
long base_position_y = 0;
std::string texture_set; // TextureSet 相对路径(原样,含反斜杠)
std::string environment; // Environment(.msenv 名)
int chunk_count() const { return map_size_x * map_size_y; }
};
// text = setting.txt 全文。缺关键字段 -> false + err。
bool parse_map_setting(const std::string& text, MapSetting& out, std::string* err);
bool parse_map_setting_file(const std::string& path, MapSetting& out, std::string* err);
} // namespace fmt
-174
View File
@@ -1,174 +0,0 @@
// M2 T1 —— .msa 解析(照 EterGrnLib/Util.cpp CGrannyMotion::LoadMotionData 的字段)。
#include "msa.h"
#include "textscript.h"
#include <cstdlib>
namespace fmt {
static void vec3(const std::vector<std::string>* l, float* out) {
if (!l) return;
for (int k = 0; k < 3 && k + 1 < (int)l->size(); ++k) out[k] = float(std::atof((*l)[k + 1].c_str()));
}
static void loop_data(const Node& root, Msa& out) {
if (const Node* loop = root.group("LoopData")) {
out.has_loop_data = true;
out.motion_loop_count = loop->inum("MotionLoopCount", -1);
out.loop_cancel_enable = loop->inum("LoopCancelEnable", 0) != 0;
out.loop_start_time = loop->num("LoopStartTime");
out.loop_end_time = loop->num("LoopEndTime");
}
}
// `List HitPosition { t lastXYZ XYZ t lastXYZ XYZ ... }` —— 7 个一组切成采样点。
static void hit_positions(const Node& win, std::vector<Msa::HitSample>& out) {
const Node* lst = win.group("HitPosition");
if (!lst || lst->lines.empty()) return;
const std::vector<std::string>& t = lst->lines[0];
for (size_t i = 0; i + 7 <= t.size(); i += 7) {
Msa::HitSample s;
s.time = float(std::atof(t[i].c_str()));
for (int k = 0; k < 3; ++k) s.last_pos[k] = float(std::atof(t[i + 1 + k].c_str()));
for (int k = 0; k < 3; ++k) s.pos[k] = float(std::atof(t[i + 4 + k].c_str()));
out.push_back(s);
}
}
// 一个命中窗(NRaceData::THitData)。老式写法里 win == AttackingData 组本身。
static Msa::HitWindow hit_window(const Node& win) {
Msa::HitWindow w;
w.start_time = win.num("AttackingStartTime");
w.end_time = win.num("AttackingEndTime");
w.bone_name = win.str("AttackingBone");
w.weapon_length = win.num("WeaponLength");
hit_positions(win, w.samples);
return w;
}
// Group ComboInputData / Group AttackingData —— 照 EterGrnLib CGrannyMotion::LoadMotionData
// 与 GameLib NRaceData::LoadMotionAttackData 的字段名读取;缺组时保持默认值。
static void combat_data(const Node& root, Msa& out) {
if (const Node* c = root.group("ComboInputData")) {
out.has_combo_input = true;
out.combo_pre_input_time = c->num("PreInputTime");
out.combo_direct_input_time = c->num("DirectInputTime");
out.combo_input_limit_time = c->num("InputLimitTime");
out.combo_link_time = c->num("LinkTime");
}
if (const Node* a = root.group("AttackingData")) {
out.has_attacking_data = true;
out.attacking_type = a->inum("AttackType", a->inum("AttackingType", 0));
out.motion_type = a->inum("MotionType", out.attacking_type);
out.hitting_type = a->inum("HittingType", 0);
out.stiffen_time = a->num("StiffenTime");
out.invisible_time = a->num("InvisibleTime");
out.external_force = a->num("ExternalForce");
out.hit_limit_count = a->inum("HitLimitCount", 0);
// 新式:HitDataCount N + Group HitData00..0N;老式:无 HitDataCount,组本身即窗。
bool has_children = false;
for (const Node& g : a->groups) {
const std::string& n = g.name;
if (n.size() >= 7 && (n.compare(0, 7, "HitData") == 0 || n.compare(0, 7, "hitdata") == 0)) {
out.hit_windows.push_back(hit_window(g));
has_children = true;
}
}
if (!has_children)
out.hit_windows.push_back(hit_window(*a));
if (!out.hit_windows.empty()) {
out.attack_start_time = out.hit_windows.front().start_time;
out.attack_end_time = out.hit_windows.front().end_time;
}
}
}
static void motion_event(const Node& ev, MotionEvent& e) {
e.type = ev.inum("MotionEventType");
e.start_time = ev.num("StartingTime");
e.duration_time = ev.num("DuringTime");
e.effect_file = ev.str("EffectFileName");
e.sound_file = ev.str("SoundFileName");
e.attaching_bone = ev.str("AttachingBoneName");
e.attaching = ev.inum("AttachingEnable") != 0;
e.following = ev.inum("FollowingEnable") != 0;
e.independent = ev.inum("IndependentFlag") != 0;
e.fishing_effect = ev.inum("FishingEffectFlag") != 0;
vec3(ev.find("EffectPosition"), e.position);
// 40250 TMotionEventDataFly has its own resource and offset fields.
e.fly_file = ev.str("FlyFileName");
e.fly_attaching_bone = ev.str("AttachingBoneName");
e.fly_attaching = ev.inum("AttachingEnable") != 0;
vec3(ev.find("FlyPosition"), e.fly_position);
// 40250 TScreenWavingEventData defaults AffectingRange to zero and reads
// DuringTime/Power from the event itself.
e.power = ev.inum("Power");
e.affecting_range = ev.inum("AffectingRange");
// 40250 TMotionAttackingEventData defaults EnableHitProcess to TRUE.
e.enable_hit_process = ev.inum("EnableHitProcess", 1) != 0;
e.attack_type = ev.inum("AttackType", ev.inum("AttackingType"));
e.hitting_type = ev.inum("HittingType");
e.stiffen_time = ev.num("StiffenTime");
e.invisible_time = ev.num("InvisibleTime");
e.external_force = ev.num("ExternalForce");
e.hit_limit_count = ev.inum("HitLimitCount");
e.collision_type = ev.inum("CollisionType");
for (const Node& g : ev.groups) {
if (g.name.size() < 10 || g.name.compare(0, 10, "SphereData") != 0)
continue;
MotionEvent::CollisionSphere sphere;
sphere.radius = g.num("Radius");
vec3(g.find("Position"), sphere.position);
e.collision_spheres.push_back(sphere);
}
}
static void motion_events(const Node& root, Msa& out) {
const Node* med = root.group("MotionEventData");
if (!med) return;
int count = med->inum("MotionEventDataCount", int(med->groups.size()));
(void)count;
for (const Node& ev : med->groups) {
MotionEvent e;
motion_event(ev, e);
out.events.push_back(std::move(e));
}
}
bool parse_msa(const std::string& text, Msa& out, std::string* err) {
Node root;
if (!parse_textscript(text, root, err)) return false;
out = Msa{};
out.motion_gr2 = root.str("MotionFileName");
out.duration = root.num("MotionDuration");
vec3(root.find("Accumulation"), out.accumulation);
loop_data(root, out);
combat_data(root, out);
motion_events(root, out);
if (out.motion_gr2.empty()) { if (err) *err = "msa: 缺 MotionFileName"; return false; }
return true;
}
bool parse_msa_file(const std::string& path, Msa& out, std::string* err) {
Node root;
if (!parse_textscript_file(path, root, err)) return false;
// 复用上面逻辑:重解析文本更简单,这里直接从 root 走一遍
out = Msa{};
out.motion_gr2 = root.str("MotionFileName");
out.duration = root.num("MotionDuration");
vec3(root.find("Accumulation"), out.accumulation);
loop_data(root, out);
combat_data(root, out);
motion_events(root, out);
if (out.motion_gr2.empty()) { if (err) *err = "msa: 缺 MotionFileName (" + path + ")"; return false; }
return true;
}
} // namespace fmt
-108
View File
@@ -1,108 +0,0 @@
// .msa —— Metin2 动作描述(文本,ScriptType MotionData)。M2 T1。
#pragma once
#include <string>
#include <vector>
namespace fmt {
struct MotionEvent {
int type = 0; // MotionEventType(1=effect, 4=special attack, 6=fly, ...)
float start_time = 0; // StartingTime
float duration_time = 0; // DuringTime(SCREEN_WAVING / SPECIAL_ATTACKING)
std::string effect_file; // EffectFileName(type 1/10)
std::string sound_file; // SoundFileName
std::string attaching_bone;
bool attaching = false;
bool following = false;
bool independent = false;
bool fishing_effect = false;
float position[3] = {0, 0, 0};
// FLY 的字段不能复用 EFFECT 的 EffectFileName/EffectPosition:40250
// 在 MotionEventDataEvent.h 中使用另一套 FlyFileName/FlyPosition。
std::string fly_file;
std::string fly_attaching_bone;
bool fly_attaching = false;
float fly_position[3] = {0, 0, 0};
// SCREEN_WAVING 参数(GameEventManager::ProcessEventScreenWaving)。
int power = 0;
int affecting_range = 0;
// SPECIAL_ATTACKING 参数(TMotionAttackingEventData)。
bool enable_hit_process = true; // 40250 缺省值为 TRUE
int attack_type = 0;
int hitting_type = 0;
float stiffen_time = 0;
float invisible_time = 0;
float external_force = 0;
int hit_limit_count = 0;
int collision_type = 0;
struct CollisionSphere {
float radius = 0;
float position[3] = {0, 0, 0};
};
std::vector<CollisionSphere> collision_spheres;
};
struct Msa {
std::string motion_gr2; // MotionFileName(原样,含 d:\... 前缀)
float duration = 0; // MotionDuration
float accumulation[3] = {0,0,0};// Accumulation(root motion 位移)
std::vector<MotionEvent> events;
bool has_loop_data = false; // LoopData 是片段循环,不等同于整段播放模式
int motion_loop_count = 0; // -1 无限;原客户端仅在 >1 或 -1 时回绕片段
bool loop_cancel_enable = false;
float loop_start_time = 0;
float loop_end_time = 0;
// Group ComboInputData —— 连击输入时间窗(EterGrnLib CGrannyMotion::LoadMotionData →
// CRaceMotionData::TComboInputData)。攻击节奏来自这里,不是硬编码间隔。
// PreInputTime → fInputStartTime 连击输入窗开始(可提前缓存下一击)
// DirectInputTime→ fNextComboTime 直接输入阈值 / 下一段连击起点(= 普攻节奏)
// InputLimitTime → fInputEndTime 连击输入窗关闭(超时归零)
// LinkTime → fComboLinkTime 段间衔接时间
bool has_combo_input = false;
float combo_pre_input_time = 0;
float combo_direct_input_time = 0;
float combo_input_limit_time = 0;
float combo_link_time = 0;
// Group AttackingData —— 命中判定窗(GameLib GameType.cpp NRaceData::TMotionAttackData)。
// 两种写法:老式(组内直接 AttackingStartTime/…/List HitPosition,无 HitDataCount)=
// 单个命中窗;新式(AttackType + MotionType + HitDataCount N + Group HitData00..0N)=
// N 个命中窗(多段挥击 / 旋风斩)。命中帧 / 硬直 / 无敌帧由动作数据驱动。
bool has_attacking_data = false;
int attacking_type = 0; // AttackType / AttackingType(SAttackData::iAttackType)
int motion_type = 0; // MotionType(缺省回退到 attacking_type)
int hitting_type = 0; // HittingType
float stiffen_time = 0; // StiffenTime(受击硬直,喂给 §3.7 / 受击方)
float invisible_time = 0; // InvisibleTime(攻击者无敌帧)
float external_force = 0; // ExternalForce(击退力度)
int hit_limit_count = 0; // HitLimitCount(0 = 不限)
// HitPosition 单个采样:time + 上一帧位置(xyz) + 当前帧位置(xyz),用于挥击扫掠球判定。
struct HitSample {
float time = 0;
float last_pos[3] = {0, 0, 0};
float pos[3] = {0, 0, 0};
};
// 一个命中窗(NRaceData::THitData)。
struct HitWindow {
float start_time = 0; // AttackingStartTime
float end_time = 0; // AttackingEndTime
std::string bone_name; // AttackingBone
float weapon_length = 0; // WeaponLength
std::vector<HitSample> samples; // List HitPosition
};
std::vector<HitWindow> hit_windows; // = THitDataContainer;老式写法长度为 1
// 兼容旧调用方:第一个命中窗的起止时间(无命中窗时为 0)。
float attack_start_time = 0;
float attack_end_time = 0;
};
bool parse_msa(const std::string& text, Msa& out, std::string* err);
bool parse_msa_file(const std::string& path, Msa& out, std::string* err);
} // namespace fmt
-37
View File
@@ -1,37 +0,0 @@
// M2 T1 —— .msm 解析(照 RaceManager.cpp CRaceData::LoadRaceData 的字段子集)。
#include "msm.h"
#include "textscript.h"
namespace fmt {
static bool fill(const Node& root, Msm& out, std::string* err) {
out = Msm{};
out.base_model_gr2 = root.str("BaseModelFileName");
out.motion_list_file = root.str("MotionListFileName");
if (const Node* hd = root.group("HairData")) {
out.hair_path = hd->str("PathName");
for (const Node& h : hd->groups) {
HairEntry e;
e.index = h.inum("HairIndex");
e.model = h.str("Model");
e.source_skin = h.str("SourceSkin");
e.target_skin = h.str("TargetSkin");
out.hairs.push_back(std::move(e));
}
}
if (out.base_model_gr2.empty()) { if (err) *err = "msm: 缺 BaseModelFileName"; return false; }
return true;
}
bool parse_msm(const std::string& text, Msm& out, std::string* err) {
Node root;
if (!parse_textscript(text, root, err)) return false;
return fill(root, out, err);
}
bool parse_msm_file(const std::string& path, Msm& out, std::string* err) {
Node root;
if (!parse_textscript_file(path, root, err)) return false;
return fill(root, out, err);
}
} // namespace fmt
-27
View File
@@ -1,27 +0,0 @@
// .msm —— Metin2 种族/模型描述(文本,ScriptType RaceDataScript)。M2 T1。
// 照 RaceManager.cpp CRaceData::LoadRaceData。
#pragma once
#include <string>
#include <vector>
namespace fmt {
struct HairEntry {
int index = 0;
std::string model; // gr2(相对 PathName)
std::string source_skin;
std::string target_skin;
};
struct Msm {
std::string base_model_gr2; // BaseModelFileName
std::string motion_list_file; // MotionListFileName
std::string hair_path; // HairData.PathName
std::vector<HairEntry> hairs;
// 武器 / 时装挂点 / 材质类型按需补(PoC 先到发型 + base)
};
bool parse_msm(const std::string& text, Msm& out, std::string* err);
bool parse_msm_file(const std::string& path, Msm& out, std::string* err);
} // namespace fmt
-168
View File
@@ -1,168 +0,0 @@
#include "property.h"
#include "m2_tokvec.h" // read_file
#include <algorithm>
#include <cctype>
#include <cstdlib>
#include <filesystem>
#include <sstream>
namespace fmt {
namespace fs = std::filesystem;
std::string Property::get(const std::string& key, const std::string& def) const {
auto it = kv.find(key);
return it == kv.end() ? def : it->second;
}
PropertyType property_type_from_string(const std::string& s) {
std::string l = s;
std::transform(l.begin(), l.end(), l.begin(), [](unsigned char c) { return (char)std::tolower(c); });
if (l == "tree") return PropertyType::Tree;
if (l == "building") return PropertyType::Building;
if (l == "effect") return PropertyType::Effect;
if (l == "ambience") return PropertyType::Ambience;
if (l == "dungeonblock") return PropertyType::DungeonBlock;
return PropertyType::Unknown;
}
bool parse_property(const std::string& text, Property& out, std::string* err) {
out = Property{};
std::istringstream in(text);
std::string line;
// 行1: YPRT
if (!std::getline(in, line)) { if (err) *err = "空文件"; return false; }
while (!line.empty() && (line.back() == '\r' || line.back() == ' ' || line.back() == '\t'))
line.pop_back();
{
std::string t;
for (char c : line) if (!std::isspace((unsigned char)c)) t += c;
if (t != "YPRT") { if (err) *err = "首行不是 YPRT: '" + t + "'"; return false; }
}
// 行2: crc
if (!std::getline(in, line)) { if (err) *err = "缺 CRC 行"; return false; }
out.crc = (uint32_t)std::strtoul(line.c_str(), nullptr, 10);
if (out.crc == 0) { if (err) *err = "CRC 行解析为 0: '" + line + "'"; return false; }
// 行3+: key\t"value"
while (std::getline(in, line)) {
if (!line.empty() && line.back() == '\r') line.pop_back();
// key = 第一段非空白
size_t i = 0, n = line.size();
while (i < n && std::isspace((unsigned char)line[i])) ++i;
if (i >= n) continue;
size_t ks = i;
while (i < n && !std::isspace((unsigned char)line[i])) ++i;
std::string key = line.substr(ks, i - ks);
std::transform(key.begin(), key.end(), key.begin(),
[](unsigned char c) { return (char)std::tolower(c); });
// value = 引号内,或剩余去空白
while (i < n && std::isspace((unsigned char)line[i])) ++i;
std::string val;
if (i < n && line[i] == '"') {
++i;
while (i < n && line[i] != '"') val += line[i++];
} else {
val = line.substr(i);
while (!val.empty() && (val.back() == '\r' || std::isspace((unsigned char)val.back())))
val.pop_back();
}
out.kv[key] = val;
}
out.name = out.get("propertyname");
out.type_str = out.get("propertytype");
out.type = property_type_from_string(out.type_str);
return true;
}
bool parse_property_file(const std::string& path, Property& out, std::string* err) {
std::string text;
if (!read_file(path, text)) { if (err) *err = "打不开 " + path; return false; }
if (!parse_property(text, out, err)) return false;
out.source_path = path;
return true;
}
const Property* PropertyRegistry::find(uint32_t crc) const {
auto it = by_crc.find(crc);
return it == by_crc.end() ? nullptr : &it->second;
}
bool PropertyRegistry::scan(const std::string& root, std::string* err) {
std::error_code ec;
if (!fs::exists(root, ec)) { if (err) *err = "目录不存在: " + root; return false; }
static const char* kExts[] = {".prb", ".prt", ".pre", ".prd", ".pra"};
for (auto it = fs::recursive_directory_iterator(
root, fs::directory_options::skip_permission_denied, ec);
it != fs::recursive_directory_iterator(); it.increment(ec)) {
if (ec) { ec.clear(); continue; }
if (!it->is_regular_file(ec)) continue;
std::string ext = it->path().extension().string();
std::transform(ext.begin(), ext.end(), ext.begin(),
[](unsigned char c) { return (char)std::tolower(c); });
if (std::find(std::begin(kExts), std::end(kExts), ext) == std::end(kExts)) continue;
++files_scanned;
Property p;
std::string e2;
if (!parse_property_file(it->path().string(), p, &e2)) {
++parse_failed;
failed_paths.push_back(it->path().string() + " : " + e2);
continue;
}
type_counts[p.type_str.empty() ? "<none>" : p.type_str]++;
auto [ins, ok] = by_crc.emplace(p.crc, p);
if (!ok) ++crc_collisions;
}
return true;
}
bool PropertyRegistry::scan_list(const std::string& root,
const std::vector<std::string>& rel_paths, std::string* err) {
static const char* kExts[] = {".prb", ".prt", ".pre", ".prd", ".pra"};
std::string r = root;
if (!r.empty() && r.back() == '/') r.pop_back();
for (const std::string& rel : rel_paths) {
// 只认顶层 Property/ 下的(对齐 scan(root + "/Property"),不吃 patch 覆盖)
if (rel.size() < 9) continue;
{
std::string top = rel.substr(0, 8);
std::transform(top.begin(), top.end(), top.begin(),
[](unsigned char c) { return (char)std::tolower(c); });
if (top != "property" || (rel[8] != '/' && rel[8] != '\\')) continue;
}
std::string ext;
size_t dot = rel.find_last_of('.');
if (dot != std::string::npos) {
ext = rel.substr(dot);
std::transform(ext.begin(), ext.end(), ext.begin(),
[](unsigned char c) { return (char)std::tolower(c); });
}
if (std::find(std::begin(kExts), std::end(kExts), ext) == std::end(kExts)) continue;
++files_scanned;
Property p;
std::string e2;
std::string full = r + "/" + rel;
if (!parse_property_file(full, p, &e2)) { // read_file -> host FileAccess -> res:// ok
++parse_failed;
failed_paths.push_back(full + " : " + e2);
continue;
}
type_counts[p.type_str.empty() ? "<none>" : p.type_str]++;
auto [ins, ok] = by_crc.emplace(p.crc, p);
if (!ok) ++crc_collisions;
}
if (files_scanned == 0) {
if (err) *err = "清单里没有属性文件(.pr*)";
return false;
}
return true;
}
} // namespace fmt
-55
View File
@@ -1,55 +0,0 @@
// Property 文件(`.prb` Building / `.prt` Tree / `.pre` Effect / `.prd` DungeonBlock /
// `.pra` Ambience)+ CRC 注册表。SHINSOO §5.6 / §9-W0 / BACKLOG E13。
//
// 文件格式(文本):
// 行1: YPRT
// 行2: <crc> 十进制 uint32
// 行3+: <key>\t"<value>" (key 小写,value 去引号)
//
// AreaData 的 property_crc 指向这里的 crc。W0 只建 crc -> Property 索引,不做实例化。
#pragma once
#include <cstdint>
#include <map>
#include <string>
#include <vector>
namespace fmt {
enum class PropertyType { Unknown, Tree, Building, Effect, Ambience, DungeonBlock };
struct Property {
uint32_t crc = 0;
PropertyType type = PropertyType::Unknown;
std::string name; // propertyname
std::string type_str; // propertytype 原字符串
std::map<std::string, std::string> kv; // 全部键值(含 buildingfile / treefile / ...)
std::string source_path; // 来源文件(诊断用)
std::string get(const std::string& key, const std::string& def = "") const;
};
PropertyType property_type_from_string(const std::string& s);
bool parse_property(const std::string& text, Property& out, std::string* err);
bool parse_property_file(const std::string& path, Property& out, std::string* err);
// 扫一个目录树,收集所有 property 文件,按 crc 索引。
struct PropertyRegistry {
std::map<uint32_t, Property> by_crc;
int files_scanned = 0;
int parse_failed = 0;
int crc_collisions = 0; // 同 crc 出现多次(保留第一个)
std::vector<std::string> failed_paths;
std::map<std::string, int> type_counts; // "Tree" -> N ...
const Property* find(uint32_t crc) const;
// root 通常是 `<assets>/Property`。递归找 *.prb/*.prt/*.pre/*.prd/*.pra(std::filesystem)。
bool scan(const std::string& root, std::string* err);
// 同上,但文件清单外部给(PCK 里没法 recursive_directory_iterator)。
// rel_paths 里挑属性扩展名的,逐个 read_file(root + "/" + rel) 解析。
bool scan_list(const std::string& root, const std::vector<std::string>& rel_paths,
std::string* err);
};
} // namespace fmt
-61
View File
@@ -1,61 +0,0 @@
#include "splat.h"
namespace fmt {
void build_splat(const TileMap& tile, int runtime_layer_count, SplatSet& out) {
out = SplatSet{};
const int S = SPLAT_RAW_XY; // 258
const auto& t = tile.raw; // 258*258
if ((int)t.size() != S * S) return;
// 直方图 + 出现过的图层
int maxidx = 0;
for (uint8_t v : t) maxidx = std::max(maxidx, (int)v);
out.tile_histogram.assign(maxidx + 1, 0);
for (uint8_t v : t) out.tile_histogram[v]++;
const int limit = std::max(runtime_layer_count - 1, maxidx);
for (int i = 1; i <= limit; ++i) {
if (i >= (int)out.tile_histogram.size() || out.tile_histogram[i] == 0) continue;
SplatLayer L;
L.layer = i;
L.alpha.resize(size_t(S) * S);
int cov = 0;
for (int y = 0; y < S; ++y) {
const uint8_t* row = &t[size_t(y) * S];
const uint8_t* up = y > 0 ? row - S : nullptr;
const uint8_t* dn = y < S - 1 ? row + S : nullptr;
uint8_t* a = &L.alpha[size_t(y) * S];
for (int x = 0; x < S; ++x) {
uint8_t tn = row[x];
if (tn == i) {
a[x] = 0xFF;
++cov;
} else if (tn > i) {
bool found = false;
if (x > 0 && row[x - 1] == i) found = true;
else if (x < S - 1 && row[x + 1] == i) found = true;
else if (up) {
if (up[x] == i) found = true;
else if (x > 0 && up[x - 1] == i) found = true;
else if (x < S - 1 && up[x + 1] == i) found = true;
}
if (!found && dn) {
if (dn[x] == i) found = true;
else if (x > 0 && dn[x - 1] == i) found = true;
else if (x < S - 1 && dn[x + 1] == i) found = true;
}
a[x] = found ? 0xFF : 0x00;
if (found) ++cov;
} else {
a[x] = 0x00;
}
}
}
L.coverage = cov;
out.layers.push_back(std::move(L));
}
}
} // namespace fmt
-30
View File
@@ -1,30 +0,0 @@
// W2 —— tile.raw -> 每图层 258×258 alpha(splat 权重)。
// 逐字节照 GameLib/AreaTerrain.cpp CTerrain::RAW_GenerateSplat:
// texel 值 = TextureSet 运行时图层索引;
// layer i 的 alpha = 0xFF 当 tile==i;当 tile>i 且 3×3 邻域有 ==i 时也 0xFF(1px 羽化);否则 0。
// SHINSOO §9-W2。无 godot 依赖。
#pragma once
#include <cstdint>
#include <vector>
#include "terrain_files.h"
namespace fmt {
constexpr int SPLAT_RAW_XY = TILEMAP_RAW_XY; // 258
struct SplatLayer {
int layer = 0; // TextureSet 运行时索引(1..N)
std::vector<uint8_t> alpha; // 258*258,行主序
int coverage = 0; // alpha==0xFF 的 texel 数(调试 / 决定是否提交)
};
struct SplatSet {
std::vector<SplatLayer> layers; // 只含在本区块出现过的图层,按索引升序
std::vector<int> tile_histogram; // [runtime_layer_index] -> texel 数(长度 = max+1)
};
// tile 来自 load_tile_map。runtime_layer_count = TextureSet.runtime_count()(含 index 0 空层)。
void build_splat(const TileMap& tile, int runtime_layer_count, SplatSet& out);
} // namespace fmt
-106
View File
@@ -1,106 +0,0 @@
#include "terrain_files.h"
#include "m2_tokvec.h" // read_file
#include <cstring>
namespace fmt {
namespace {
uint16_t rd_u16(const uint8_t* p) { return uint16_t(p[0]) | (uint16_t(p[1]) << 8); }
} // namespace
bool load_height_map(const std::string& path, HeightMap& out, std::string* err) {
std::string buf;
if (!read_file(path, buf)) { if (err) *err = "打不开 " + path; return false; }
const size_t need = size_t(HEIGHTMAP_RAW_XY) * HEIGHTMAP_RAW_XY * 2;
if (buf.size() != need) {
if (err) *err = "height.raw 大小 " + std::to_string(buf.size()) +
" != " + std::to_string(need) + "(131*131*2)";
return false;
}
out.raw.resize(size_t(HEIGHTMAP_RAW_XY) * HEIGHTMAP_RAW_XY);
const auto* p = reinterpret_cast<const uint8_t*>(buf.data());
for (size_t i = 0; i < out.raw.size(); ++i) out.raw[i] = rd_u16(p + i * 2);
return true;
}
bool load_tile_map(const std::string& path, TileMap& out, std::string* err) {
std::string buf;
if (!read_file(path, buf)) { if (err) *err = "打不开 " + path; return false; }
const size_t need = size_t(TILEMAP_RAW_XY) * TILEMAP_RAW_XY;
if (buf.size() != need) {
if (err) *err = "tile.raw 大小 " + std::to_string(buf.size()) +
" != " + std::to_string(need) + "(258*258)";
return false;
}
out.raw.assign(buf.begin(), buf.end());
return true;
}
bool load_attr_map(const std::string& path, AttrMap& out, std::string* err) {
std::string buf;
if (!read_file(path, buf)) { if (err) *err = "打不开 " + path; return false; }
const size_t payload = size_t(ATTRMAP_XY) * ATTRMAP_XY;
if (buf.size() != 6 + payload) {
if (err) *err = "attr.atr 大小 " + std::to_string(buf.size()) +
" != " + std::to_string(6 + payload) + "(6B 头 + 256*256)";
return false;
}
const auto* p = reinterpret_cast<const uint8_t*>(buf.data());
uint16_t magic = rd_u16(p), w = rd_u16(p + 2), h = rd_u16(p + 4);
if (magic != ATTR_MAGIC) {
if (err) *err = "attr.atr magic " + std::to_string(magic) + " != 2634";
return false;
}
if (w != ATTRMAP_XY || h != ATTRMAP_XY) {
if (err) *err = "attr.atr 尺寸头 " + std::to_string(w) + "x" + std::to_string(h) +
" != 256x256";
return false;
}
out.data.assign(buf.begin() + 6, buf.end());
return true;
}
bool load_water_map(const std::string& path, WaterMap& out, std::string* err) {
std::string buf;
if (!read_file(path, buf)) { if (err) *err = "打不开 " + path; return false; }
if (buf.size() < 7) { if (err) *err = "water.wtr 太小"; return false; }
const auto* p = reinterpret_cast<const uint8_t*>(buf.data());
uint16_t magic = rd_u16(p), w = rd_u16(p + 2), h = rd_u16(p + 4);
uint8_t layers = p[6];
if (magic != WATER_MAGIC) {
if (err) *err = "water.wtr magic " + std::to_string(magic) + " != 5426";
return false;
}
if (w != WATERMAP_XY || h != WATERMAP_XY) {
if (err) *err = "water.wtr 尺寸头 != 128x128";
return false;
}
const size_t ids = size_t(WATERMAP_XY) * WATERMAP_XY;
const size_t rest = buf.size() - 7;
// Terrain.cpp 接受两种:u16 层高 或 long(u32) 层高。
size_t hsize = 0;
if (rest == ids + size_t(layers) * 2) hsize = 2;
else if (rest == ids + size_t(layers) * 4) hsize = 4;
else if (rest == ids && layers == 0) hsize = 0;
else {
if (err) *err = "water.wtr 数据段大小 " + std::to_string(rest) +
" 与 layers=" + std::to_string(layers) + " 不符";
return false;
}
out.layer_count = layers;
out.ids.assign(buf.begin() + 7, buf.begin() + 7 + ids);
out.heights.clear();
const auto* hp = p + 7 + ids;
for (int i = 0; i < layers; ++i) {
if (hsize == 2) out.heights.push_back((int16_t)rd_u16(hp + i * 2));
else if (hsize == 4)
out.heights.push_back((int32_t)(uint32_t(hp[i * 4]) | (uint32_t(hp[i * 4 + 1]) << 8) |
(uint32_t(hp[i * 4 + 2]) << 16) |
(uint32_t(hp[i * 4 + 3]) << 24)));
}
return true;
}
} // namespace fmt
-44
View File
@@ -1,44 +0,0 @@
// 区块二进制:height.raw / tile.raw / attr.atr / water.wtr。
// 真值来源:PRTerrainLib/Terrain.cpp(LoadHeightMap / RAW_LoadTileMap / LoadAttrMap /
// LoadWaterMapFile)+ Terrain.h / TerrainType.h 常量。SHINSOO §5.3。
//
// XSIZE = 128
// height.raw : 无头,u16 LE [131*131] (HEIGHTMAP_RAW = XSIZE+3)
// tile.raw : 无头,u8 [258*258] (TILEMAP_RAW = XSIZE*2+2)
// attr.atr : 6B 头 {u16 magic=2634, u16 w=256, u16 h=256} + u8 [256*256]
// water.wtr : 7B 头 {u16 magic=5426, u16 w=128, u16 h=128, u8 layers}
// + u8 [128*128] + layers×(u16 或 u32) 层高
#pragma once
#include <cstdint>
#include <string>
#include <vector>
namespace fmt {
constexpr int HEIGHTMAP_RAW_XY = 131; // 128+3
constexpr int TILEMAP_RAW_XY = 258; // 128*2+2
constexpr int ATTRMAP_XY = 256; // 128*2
constexpr int WATERMAP_XY = 128;
constexpr uint16_t ATTR_MAGIC = 2634;
constexpr uint16_t WATER_MAGIC = 5426;
struct HeightMap {
std::vector<uint16_t> raw; // 131*131, 行主序(含 1 圈边界样本)
uint16_t at(int sx, int sy) const { // 与 Terrain.h GetHeight 一致:+1 偏移
return raw[(sy + 1) * HEIGHTMAP_RAW_XY + (sx + 1)];
}
};
struct TileMap { std::vector<uint8_t> raw; }; // 258*258
struct AttrMap { std::vector<uint8_t> data; }; // 256*256(去头)
struct WaterMap {
uint8_t layer_count = 0;
std::vector<uint8_t> ids; // 128*128;0xFF = 无水
std::vector<int32_t> heights; // layer_count 个
};
bool load_height_map(const std::string& path, HeightMap& out, std::string* err);
bool load_tile_map(const std::string& path, TileMap& out, std::string* err);
bool load_attr_map(const std::string& path, AttrMap& out, std::string* err);
bool load_water_map(const std::string& path, WaterMap& out, std::string* err);
} // namespace fmt
-102
View File
@@ -1,102 +0,0 @@
#include "terrain_mesh.h"
#include "m2_coord.h"
#include <algorithm>
#include <cmath>
namespace fmt {
namespace {
constexpr int CS = m2coord::CELLSCALE; // 200 cm
constexpr double CS_M = double(CS) * m2coord::CM_TO_M; // 2.0 m
} // namespace
double terrain_height_at(const HeightMap& hm, double lx_cm, double ly_cm, double hs) {
// 照 CTerrain::GetHeight(本地坐标版,调用方已把全局->本地 + y 取绝对值)
if (lx_cm < 0 || ly_cm < 0 || lx_cm > double(m2coord::TERRAIN_SIZE) * CS ||
ly_cm > double(m2coord::TERRAIN_SIZE) * CS)
return 0.0;
long xi = (long)lx_cm, yi = (long)ly_cm;
long xdist = xi % CS, ydist = yi % CS;
long x = xi / CS, y = yi / CS;
const double oo = 1.0 / double(CS);
double h1 = hm.at((int)x, (int)y) * hs; // TL
double h2 = hm.at((int)x + 1, (int)y + 1) * hs; // BR
if (xdist <= ydist) { // 左三角:TL, BR, BL
double h3 = hm.at((int)x, (int)y + 1) * hs;
double xslope = (h2 - h3) * oo;
double yslope = (h3 - h1) * oo;
return h1 + (xdist * xslope + ydist * yslope);
}
// 右三角:TL, BR, TR
double h3 = hm.at((int)x + 1, (int)y) * hs;
double xslope = (h3 - h1) * oo;
double yslope = (h2 - h3) * oo;
return h1 + (xdist * xslope + ydist * yslope);
}
void build_terrain_mesh(const HeightMap& hm, int tile_x, int tile_y, double hs,
TerrainMesh& out, bool ccw_from_above) {
out = TerrainMesh{};
const int N = TerrainMesh::VERTS_XY; // 129
out.positions.resize(size_t(N) * N * 3);
out.normals.resize(size_t(N) * N * 3);
out.uvs.resize(size_t(N) * N * 2);
out.indices.reserve(size_t(TerrainMesh::QUADS_XY) * TerrainMesh::QUADS_XY * 6);
// Godot 空间原点:见 area_data.h 说明 —— 区块 y 取负,position_to_godot 再翻正。
const double X0 = double(tile_x) * m2coord::CHUNK_CM * m2coord::CM_TO_M;
const double Z0 = double(tile_y) * m2coord::CHUNK_CM * m2coord::CM_TO_M;
auto height_m = [&](int i, int j) -> double {
return hm.at(i, j) * hs * m2coord::CM_TO_M;
};
double miny = 1e30, maxy = -1e30;
for (int j = 0; j < N; ++j) {
for (int i = 0; i < N; ++i) {
const double gy = height_m(i, j);
const size_t o = (size_t(j) * N + i) * 3;
out.positions[o + 0] = float(X0 + i * CS_M);
out.positions[o + 1] = float(gy);
out.positions[o + 2] = float(Z0 + j * CS_M);
const size_t uo = (size_t(j) * N + i) * 2;
out.uvs[uo + 0] = float(i) / float(TerrainMesh::QUADS_XY);
out.uvs[uo + 1] = float(j) / float(TerrainMesh::QUADS_XY);
miny = std::min(miny, gy);
maxy = std::max(maxy, gy);
// 中心差分(用边界样本,跨区块无缝);y=f(x,z) 的法线 = (-df/dx, 1, -df/dz)
const double dh_di = (height_m(i + 1, j) - height_m(i - 1, j)) / (2.0 * CS_M);
const double dh_dj = (height_m(i, j + 1) - height_m(i, j - 1)) / (2.0 * CS_M);
double nx = -dh_di, ny = 1.0, nz = -dh_dj;
const double len = std::sqrt(nx * nx + ny * ny + nz * nz);
out.normals[o + 0] = float(nx / len);
out.normals[o + 1] = float(ny / len);
out.normals[o + 2] = float(nz / len);
}
}
out.min_y = float(miny);
out.max_y = float(maxy);
auto vid = [N](int i, int j) { return int32_t(j * N + i); };
for (int j = 0; j < TerrainMesh::QUADS_XY; ++j) {
for (int i = 0; i < TerrainMesh::QUADS_XY; ++i) {
int32_t TL = vid(i, j), TR = vid(i + 1, j);
int32_t BL = vid(i, j + 1), BR = vid(i + 1, j + 1);
// 对角线 TL-BR(= GetHeight 的 h1/h2)
if (ccw_from_above) {
out.indices.insert(out.indices.end(), {TL, BR, BL});
out.indices.insert(out.indices.end(), {TL, TR, BR});
} else {
out.indices.insert(out.indices.end(), {TL, BL, BR});
out.indices.insert(out.indices.end(), {TL, BR, TR});
}
}
}
}
} // namespace fmt
-38
View File
@@ -1,38 +0,0 @@
// W1 —— 一个区块的地形几何(heightmap -> 顶点 / 法线 / 索引),纯数学、无 godot 依赖。
// 三角划分与法线照 GameLib/AreaTerrain.cpp CTerrain::GetHeight / CalculateNormal。
// SHINSOO §9-W1。
//
// 129×129 可见顶点,128×128 quad。每个 quad 对角线 = TL->BR(GetHeight 的 h1/h2)。
// 顶点已做 Metin2(cm,Z-up) -> Godot(m,Y-up) 转换(m2_coord),并加了区块原点。
#pragma once
#include <cstdint>
#include <vector>
#include "terrain_files.h"
namespace fmt {
struct TerrainMesh {
static constexpr int VERTS_XY = 129; // XSIZE+1
static constexpr int QUADS_XY = 128; // XSIZE
std::vector<float> positions; // 3/vert,Godot 空间(米,Y-up),含区块原点
std::vector<float> normals; // 3/vert,Godot 空间,单位向量
std::vector<float> uvs; // 2/vert,区块内 [0,1](用于 splat / 地表贴图)
std::vector<int32_t> indices; // 3/tri,6/quad
float min_y = 0, max_y = 0; // Godot Y(高度)范围,做 custom_aabb / 相机用
int vertex_count() const { return VERTS_XY * VERTS_XY; }
int index_count() const { return QUADS_XY * QUADS_XY * 6; }
};
// height_scale 从 MapSetting 取(A1 = 0.5)。tile_x/tile_y 决定区块原点。
// ccw_from_above=true:三角绕序在 Godot 里从上方看为逆时针(默认,若发现背面朝上则传 false)。
void build_terrain_mesh(const HeightMap& hm, int tile_x, int tile_y, double height_scale,
TerrainMesh& out, bool ccw_from_above = true);
// 与 CTerrain::GetHeight 一致的三角插值。lx/ly = 区块本地厘米([0, 128*200])。
// 返回厘米高度(未转 Godot 单位)。
double terrain_height_at(const HeightMap& hm, double lx_cm, double ly_cm, double height_scale);
} // namespace fmt
-132
View File
@@ -1,132 +0,0 @@
#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;
}
-142
View File
@@ -1,142 +0,0 @@
// M2 §3.5 —— playersettingmodule.py 的 ComboAttackNew 调用 -> PC 连击段表。
// 照 GameLib/RaceData.cpp CRaceData::ReserveComboAttack / RegisterComboAttack 语义。
#include <combo_table.h>
#include <cstdio>
#include <cstdlib>
#include <string>
using namespace fmt;
static int g_fail = 0;
#define CHECK(c, msg) \
do { \
if (!(c)) { \
std::fprintf(stderr, "FAIL: %s\n", (msg)); \
++g_fail; \
} \
} while (0)
// warrior_m/_w 共用的 __LoadGameWarriorEx 片段 + assassin 的 DUALHAND(男限),
// 外加一个非 Ex 的 def 用来验证块复位。缩进 / tab 混用照原文。
static const char *kPy = R"PY(
COMBO_TYPE_1 = 0
COMBO_TYPE_2 = 1
COMBO_TYPE_3 = 2
COMBO_INDEX_1 = 0
COMBO_INDEX_6 = 5
def __LoadGameWarriorEx(race, path):
chrmgr.SelectRace(race)
chrmgr.ReserveComboAttackNew(chr.MOTION_MODE_GENERAL, COMBO_TYPE_1, 1)
chrmgr.RegisterComboAttackNew(chr.MOTION_MODE_GENERAL, COMBO_TYPE_1, COMBO_INDEX_1, chr.MOTION_COMBO_ATTACK_1)
## Combo Type 1
chrmgr.ReserveComboAttackNew(chr.MOTION_MODE_ONEHAND_SWORD, COMBO_TYPE_1, 4)
chrmgr.RegisterComboAttackNew(chr.MOTION_MODE_ONEHAND_SWORD, COMBO_TYPE_1, COMBO_INDEX_1, chr.MOTION_COMBO_ATTACK_1)
chrmgr.RegisterComboAttackNew(chr.MOTION_MODE_ONEHAND_SWORD, COMBO_TYPE_1, COMBO_INDEX_2, chr.MOTION_COMBO_ATTACK_2)
chrmgr.RegisterComboAttackNew(chr.MOTION_MODE_ONEHAND_SWORD, COMBO_TYPE_1, COMBO_INDEX_3, chr.MOTION_COMBO_ATTACK_3)
chrmgr.RegisterComboAttackNew(chr.MOTION_MODE_ONEHAND_SWORD, COMBO_TYPE_1, COMBO_INDEX_4, chr.MOTION_COMBO_ATTACK_4)
## Combo Type 2 —— 段号跳到 5 / 7(非连续,验证按下标写而非追加)
chrmgr.ReserveComboAttackNew(chr.MOTION_MODE_ONEHAND_SWORD, COMBO_TYPE_2, 5)
chrmgr.RegisterComboAttackNew(chr.MOTION_MODE_ONEHAND_SWORD, COMBO_TYPE_2, COMBO_INDEX_1, chr.MOTION_COMBO_ATTACK_1)
chrmgr.RegisterComboAttackNew(chr.MOTION_MODE_ONEHAND_SWORD, COMBO_TYPE_2, COMBO_INDEX_2, chr.MOTION_COMBO_ATTACK_2)
chrmgr.RegisterComboAttackNew(chr.MOTION_MODE_ONEHAND_SWORD, COMBO_TYPE_2, COMBO_INDEX_3, chr.MOTION_COMBO_ATTACK_3)
chrmgr.RegisterComboAttackNew(chr.MOTION_MODE_ONEHAND_SWORD, COMBO_TYPE_2, COMBO_INDEX_4, chr.MOTION_COMBO_ATTACK_5)
chrmgr.RegisterComboAttackNew(chr.MOTION_MODE_ONEHAND_SWORD, COMBO_TYPE_2, COMBO_INDEX_5, chr.MOTION_COMBO_ATTACK_7)
# 越界的 Register(下标 5 >= size 5):照参考端 AssertLog + return,忽略
chrmgr.RegisterComboAttackNew(chr.MOTION_MODE_ONEHAND_SWORD, COMBO_TYPE_2, 5, chr.MOTION_COMBO_ATTACK_8)
# 对已存在 key 的重复 Reserve:map::insert 不覆盖,原向量保留
chrmgr.ReserveComboAttackNew(chr.MOTION_MODE_ONEHAND_SWORD, COMBO_TYPE_2, 99)
def __LoadGameAssassinEx(race, path):
chrmgr.SelectRace(race)
chrmgr.ReserveComboAttackNew(chr.MOTION_MODE_DUALHAND_SWORD, COMBO_TYPE_3, 6)
chrmgr.RegisterComboAttackNew(chr.MOTION_MODE_DUALHAND_SWORD, COMBO_TYPE_3, COMBO_INDEX_6, chr.MOTION_COMBO_ATTACK_8)
def __LoadGameNPC():
# 块外的调用不该落到任何职业里
chrmgr.ReserveComboAttackNew(chr.MOTION_MODE_GENERAL, COMBO_TYPE_1, 3)
)PY";
static void check_vec(const std::vector<uint16_t> *v, std::initializer_list<uint16_t> want, const char *msg) {
if (!v) { std::fprintf(stderr, "FAIL: %s (nullptr)\n", msg); ++g_fail; return; }
if (v->size() != want.size()) { std::fprintf(stderr, "FAIL: %s (size %zu != %zu)\n", msg, v->size(), want.size()); ++g_fail; return; }
size_t i = 0;
for (uint16_t w : want) {
if ((*v)[i] != w) { std::fprintf(stderr, "FAIL: %s ([%zu]=%u != %u)\n", msg, i, (*v)[i], w); ++g_fail; return; }
++i;
}
}
int main() {
// ---- key 编码:RaceData.h MAKE_COMBO_KEY -------------------------------
CHECK(make_combo_key(COMBO_MODE_ONEHAND_SWORD, 1) == ((2u << 16) | 1u), "make_combo_key");
CHECK(combo_key_mode(make_combo_key(5, 2)) == 5 && combo_key_type(make_combo_key(5, 2)) == 2, "combo_key split");
// ---- 解析 ------------------------------------------------------------
PlayerComboTables tbl;
std::string err;
if (!parse_player_combo_tables(kPy, tbl, &err)) {
std::fprintf(stderr, "parse failed: %s\n", err.c_str());
return 1;
}
const ComboTable &w = tbl.klass(COMBO_CLASS_WARRIOR);
const ComboTable &a = tbl.klass(COMBO_CLASS_ASSASSIN);
const ComboTable &s = tbl.klass(COMBO_CLASS_SURA);
// GENERAL / type1 —— 单段普攻连击(NAME_COMBO_ATTACK_1 = 14)
check_vec(w.get(COMBO_MODE_GENERAL, 0), {14}, "warrior GENERAL type1 = [14]");
// ONEHAND_SWORD / type1 —— 连续 4 段 14..17
check_vec(w.get(COMBO_MODE_ONEHAND_SWORD, 0), {14, 15, 16, 17}, "warrior 1H type1 = [14,15,16,17]");
// ONEHAND_SWORD / type2 —— 5 段,末两段跳到 18 / 20(COMBO_ATTACK_5 / _7)
check_vec(w.get(COMBO_MODE_ONEHAND_SWORD, 1), {14, 15, 16, 18, 20}, "warrior 1H type2 = [14,15,16,18,20]");
// 越界 Register 被忽略;重复 Reserve 不覆盖(size 仍是 5,不是 99)
CHECK(w.get(COMBO_MODE_ONEHAND_SWORD, 1)->size() == 5, "dup Reserve ignored / OOB Register ignored");
// type3 从没 Reserve 过 -> GetComboDataPointer 应为 nullptr
CHECK(w.get(COMBO_MODE_ONEHAND_SWORD, 2) == nullptr, "warrior 1H type3 absent -> nullptr");
// 职业隔离:DUALHAND 只在 assassin 块里;warrior/sura 没有
check_vec(a.get(COMBO_MODE_DUALHAND_SWORD, 2), {0, 0, 0, 0, 0, 21}, "assassin DUAL type3 idx5 = COMBO_ATTACK_8(21)");
CHECK(w.get(COMBO_MODE_DUALHAND_SWORD, 2) == nullptr, "warrior has no DUALHAND table");
CHECK(s.empty(), "sura block absent in snippet -> empty table");
// 块外(__LoadGameNPC)的 Reserve 不该落进任何职业
size_t total = 0;
for (int c = 0; c < COMBO_CLASS_COUNT; ++c) total += tbl.klass(c).key_count();
CHECK(total == 4, "only the 4 in-Ex-block combo keys registered (2 warrior 1H + 1 warrior GENERAL + 1 assassin DUAL)");
// 越界职业索引 -> 空表,不崩
CHECK(tbl.klass(-1).empty() && tbl.klass(99).empty(), "out-of-range class -> empty");
// ---- 可选:真实 playersettingmodule.py(设了 M2_ASSETS 才跑)----------
if (const char *root = std::getenv("M2_ASSETS")) {
const std::string path = std::string(root) + "/root/playersettingmodule.py";
PlayerComboTables live;
std::string lerr;
if (parse_player_combo_tables_file(path, live, &lerr)) {
check_vec(live.klass(COMBO_CLASS_WARRIOR).get(COMBO_MODE_ONEHAND_SWORD, 0),
{14, 15, 16, 17}, "live warrior 1H type1");
// sura ONEHAND type3 末段回到 COMBO_ATTACK_4 (17)
const auto *sv = live.klass(COMBO_CLASS_SURA).get(COMBO_MODE_ONEHAND_SWORD, 2);
CHECK(sv && sv->size() == 6 && sv->back() == 17, "live sura 1H type3 tail = COMBO_ATTACK_4");
// shaman FAN type1 = 4 连续段
check_vec(live.klass(COMBO_CLASS_SHAMAN).get(COMBO_MODE_FAN, 0),
{14, 15, 16, 17}, "live shaman FAN type1");
std::printf("live playersettingmodule.py: OK\n");
} else {
std::fprintf(stderr, "live parse skipped: %s\n", lerr.c_str());
}
}
if (g_fail == 0) {
std::printf("PASS: formats_combo_table_test (§3.5 PC 连击段表)\n");
}
return g_fail == 0 ? 0 : 1;
}
-544
View File
@@ -1,544 +0,0 @@
// W0 —— A1 地图格式解析单测。SHINSOO §9-W0 / §11.1。
//
// 无参数:跑内联字符串单测(不需要资源)。
// 环境变量 M2_ASSETS 指向 Metin2 资产目录(默认 <repo>/assets):额外跑真实 A1 的 W0 验收断言。
#include "area_data.h"
#include "asset_resolver.h"
#include "environment.h"
#include "m2_coord.h"
#include "m2_tokvec.h"
#include "map_setting.h"
#include "property.h"
#include "splat.h"
#include "spt.h"
#include "terrain_files.h"
#include "terrain_mesh.h"
#include "texture_set.h"
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <string>
static int g_fail = 0;
#define CHECK(cond, msg) \
do { \
if (!(cond)) { \
std::fprintf(stderr, "FAIL: %s (%s:%d)\n", (msg), __FILE__, __LINE__); \
++g_fail; \
} \
} while (0)
static void test_tokvec() {
const std::string t = R"(
ScriptType MapSetting
MapSize 4 5
Start Object000
1.5 -2.0 3.0
569394331
End Object
# comment
Start Object000
9 9 9
End Object
)";
fmt::TokVecMap tv;
std::string err;
CHECK(fmt::parse_tokvec(t, tv, &err), "tokvec parse");
CHECK(tv.str("scripttype") == "MapSetting", "tokvec top-level str");
const auto* ms = tv.find("MapSize"); // 大小写不敏感
CHECK(ms && ms->size() == 2 && (*ms)[1] == "5", "tokvec MapSize tokens");
const auto* o0 = tv.find("object000");
CHECK(o0 && o0->size() == 4, "tokvec block flattens all lines");
CHECK(o0 && (*o0)[3] == "569394331", "tokvec block token order");
CHECK(tv.dup_count == 1, "tokvec dup detected");
}
static void test_coord() {
using namespace fmt::m2coord;
Vec3 g = position_to_godot(100.0, 200.0, 300.0);
CHECK(std::fabs(g.x - 1.0) < 1e-9, "coord x");
CHECK(std::fabs(g.y - 3.0) < 1e-9, "coord y = z*0.01");
CHECK(std::fabs(g.z + 2.0) < 1e-9, "coord z = -y*0.01");
CHECK(tile_id(1, 3) == 1003u, "tile_id");
CHECK(tile_dir(0, 0) == "000000", "tile_dir 0");
CHECK(tile_dir(1, 3) == "001003", "tile_dir 1,3");
int tx = -1, ty = -1;
CHECK(parse_tile_dir("003004", tx, ty) && tx == 3 && ty == 4, "parse_tile_dir");
CHECK(!parse_tile_dir("00300", tx, ty), "parse_tile_dir rejects len!=6");
CHECK(CHUNK_CM == 25600, "chunk cm");
// ypr_basis = 原始 D3DXMatrixRotationYawPitchRoll 端口,在 **Metin2 Z-up 空间**。
Mat3 y90 = ypr_basis(90.0, 0, 0); // yaw 绕 Metin2 Y(未转轴)
// m 是 row-major,基向量 = 列:new_x = (m[0],m[3],m[6])
CHECK(std::fabs(y90.m[0]) < 1e-6 && std::fabs(y90.m[6] + 1.0) < 1e-6,
"ypr yaw=90: local +X -> -Z (Metin2 space)");
Mat3 id = ypr_basis(0, 0, 0);
CHECK(std::fabs(id.m[0] - 1) < 1e-9 && std::fabs(id.m[4] - 1) < 1e-9 &&
std::fabs(id.m[8] - 1) < 1e-9,
"ypr zero = identity");
// object_basis_godot = ypr_basis 共轭到 Godot Y-up。放置物体必须用这个。
Mat3 og0 = object_basis_godot(0, 0, 0);
CHECK(std::fabs(og0.m[0] - 1) < 1e-9 && std::fabs(og0.m[4] - 1) < 1e-9 &&
std::fabs(og0.m[8] - 1) < 1e-9,
"object_basis_godot zero = identity");
// areadata 单值旋转 = roll = 朝向。共轭后必须是绕 Godot +Y 的 heading,
// 不是绕 Godot Z 的翻滚(修复前的 bug)。
Mat3 rl90 = object_basis_godot(0, 0, 90.0);
CHECK(std::fabs(rl90.m[1]) < 1e-6 && std::fabs(rl90.m[4] - 1.0) < 1e-6 &&
std::fabs(rl90.m[7]) < 1e-6,
"object_basis_godot roll=90: Godot +Y axis fixed (heading, no roll)");
CHECK(std::fabs(rl90.m[0]) < 1e-6 && std::fabs(rl90.m[6] + 1.0) < 1e-6,
"object_basis_godot roll=90: local +X -> Godot -Z");
// 正交且右手(det=+1):取一个混合角
Mat3 m = object_basis_godot(20.0, -35.0, 110.0);
double det = m.m[0] * (m.m[4] * m.m[8] - m.m[5] * m.m[7]) -
m.m[1] * (m.m[3] * m.m[8] - m.m[5] * m.m[6]) +
m.m[2] * (m.m[3] * m.m[7] - m.m[4] * m.m[6]);
CHECK(std::fabs(det - 1.0) < 1e-6, "object_basis_godot det = +1");
double col0 = m.m[0] * m.m[0] + m.m[3] * m.m[3] + m.m[6] * m.m[6];
CHECK(std::fabs(col0 - 1.0) < 1e-6, "object_basis_godot col0 unit");
}
static void test_map_setting() {
const std::string t = R"(
ScriptType MapSetting
CellScale 200
HeightScale 0.500000
ViewRadius 128
MapSize 4 5
BasePosition 409600 896000
TextureSet textureset\metin2_A1.txt
Environment A1.msenv
)";
fmt::MapSetting s;
std::string err;
CHECK(fmt::parse_map_setting(t, s, &err), "map_setting parse");
CHECK(s.cell_scale == 200, "cell_scale");
CHECK(std::fabs(s.height_scale - 0.5) < 1e-9, "height_scale");
CHECK(s.map_size_x == 4 && s.map_size_y == 5, "map_size");
CHECK(s.chunk_count() == 20, "chunk_count");
CHECK(s.base_position_x == 409600 && s.base_position_y == 896000, "base_position");
CHECK(s.environment == "A1.msenv", "environment");
}
static void test_texture_set() {
const std::string t = R"(
TextureSet
TextureCount 2
Start Texture001
"d:\ymir work\terrainmaps\b\field\field 01.dds"
5.0 5.0 0.0 0.0 0 0 0
End Texture001
Start Texture002
"d:\ymir work\terrainmaps\b\field\field 02.dds"
6.0 6.0 0.0 0.0 1 10 20
End Texture002
)";
fmt::TextureSet ts;
std::string err;
CHECK(fmt::parse_texture_set(t, ts, &err), "texture_set parse");
CHECK(ts.declared_count == 2, "declared_count");
CHECK(ts.layers.size() == 2, "layers");
CHECK(ts.runtime_count() == 3, "runtime_count = layers + 1 (index0 empty)");
CHECK(ts.layers[1].splat_enabled && ts.layers[1].height_end == 20, "layer fields");
CHECK(ts.layers[0].texture.find("field 01.dds") != std::string::npos, "quoted path w/ space");
}
static void test_area_data() {
const std::string t = R"(
AreaDataFile
Start Object000
20801.746094 -13522.107422 17875.500000
569394331
0.000000#0.000000#180.000000
-40.000000
End Object
Start Object001
17069.0 -11315.7 17926.9
865570388
0.000000#15.000000#225.000000
End Object
ObjectCount 2
)";
fmt::AreaData a;
std::string err;
CHECK(fmt::parse_area_data(t, a, &err), "area_data parse");
CHECK(a.declared_count == 2, "declared");
CHECK(a.objects.size() == 2, "objects");
CHECK(a.objects[0].crc == 569394331u, "crc");
CHECK(std::fabs(a.objects[0].roll - 180.0f) < 1e-3f, "ypr roll");
CHECK(std::fabs(a.objects[0].height_bias + 40.0f) < 1e-3f, "height_bias");
CHECK(a.objects[0].x > 0 && a.objects[0].y < 0, "global cm: x>0 (east), y<0 (south)");
CHECK(std::fabs(a.objects[1].pitch - 15.0f) < 1e-3f && a.objects[1].height_bias == 0.0f,
"ypr without height_bias");
}
static void test_environment() {
const std::string t = R"(
ScriptType EnvrionmentData
ScriptVersion 1.0000
Group DirectionalLight
{
Direction 0.35 0.56 -0.75
Group Background
{
Enable 1
Diffuse 1.0 0.97 0.97 1.0
Ambient 0.0 0.0 0.0 1.0
}
Group Character
{
Enable 1
Ambient 0.15 0.15 0.15 1.0
}
}
Group Fog
{
foglevel 6
IsDensity 1
Color 0.69 0.74 0.83 1.0
}
Group Filter
{
Enable 1
Color 0.1 0.2 0.3 0.4
AlphaSrc 5
AlphaDest 6
}
Group SkyBox
{
BTextureRenderMode 1
Scale 3500.0 3500.0 3500.0
CloudTextureFileName "d:/ymir work/environment/clouds_zone01.tga"
FrontFaceFileName "d:/ymir work/environment/skybox/front.dds"
List Gradient
{
0.09 0.28 0.65 0.0
0.23 0.40 0.72 0.0
0.37 0.49 0.72 0.0
}
}
)";
fmt::Environment e;
std::string err;
CHECK(fmt::parse_environment(t, e, &err), ("env parse: " + err).c_str());
CHECK(e.script_type == "EnvrionmentData", "env script_type (misspelled, accepted)");
CHECK(std::fabs(e.dir_light.direction[2] + 0.75f) < 1e-3f, "env dir light Direction z");
CHECK(e.dir_light.bg_enable && e.dir_light.ch_enable, "env bg/ch enable");
CHECK(std::fabs(e.dir_light.bg_diffuse[1] - 0.97f) < 1e-3f, "env bg diffuse");
CHECK(e.fog.enable && e.fog.fog_level == 6, "env fog (foglevel form)");
CHECK(e.fog.density_fog, "env density fog flag");
CHECK(e.filter.enable && e.filter.alpha_src == 5 && e.filter.alpha_dest == 6,
"env screen filter fields");
CHECK(std::fabs(e.filter.color[2] - 0.3f) < 1e-3f, "env screen filter color");
CHECK(e.sky.texture_render_mode && e.sky.face_textures[0].find("front.dds") != std::string::npos,
"env skybox texture mode + front face");
CHECK(std::fabs(e.sky.scale[0] - 3500.0f) < 1e-1f, "env skybox scale");
CHECK(e.sky.cloud_texture.find("clouds_zone01.tga") != std::string::npos, "env cloud tex path");
CHECK(e.sky.gradient.size() == 3, "env List Gradient rows");
CHECK(std::fabs(e.sky.gradient[2][0] - 0.37f) < 1e-3f, "env List row values");
}
static void test_property() {
const std::string prb = "YPRT\n274490822\nbuildingfile\t\"d:/ymir work/zone/x.gr2\"\n"
"propertyname\t\"rock_pillar_02\"\npropertytype\t\"Building\"\n"
"shadowflag\t\"1\"\n";
fmt::Property p;
std::string err;
CHECK(fmt::parse_property(prb, p, &err), ("property parse: " + err).c_str());
CHECK(p.crc == 274490822u, "property crc");
CHECK(p.type == fmt::PropertyType::Building, "property type Building");
CHECK(p.name == "rock_pillar_02", "property name");
CHECK(p.get("buildingfile").find("x.gr2") != std::string::npos, "property buildingfile");
CHECK(p.get("shadowflag") == "1", "property shadowflag");
fmt::Property bad;
CHECK(!fmt::parse_property("NOPE\n123\n", bad, &err), "property rejects non-YPRT");
}
static void test_terrain_mesh() {
// 合成一张 131x131 heightmap:平坦 + 一个已知斜坡
fmt::HeightMap hm;
hm.raw.assign(size_t(fmt::HEIGHTMAP_RAW_XY) * fmt::HEIGHTMAP_RAW_XY, 1000);
// 在 (sx,sy)=(10,10) 抬高
auto set = [&](int sx, int sy, uint16_t v) {
hm.raw[(sy + 1) * fmt::HEIGHTMAP_RAW_XY + (sx + 1)] = v;
};
set(10, 10, 2000);
fmt::TerrainMesh tm;
fmt::build_terrain_mesh(hm, 0, 0, 0.5, tm);
CHECK(tm.vertex_count() == 129 * 129, "terrain vert count");
CHECK((int)tm.positions.size() == 129 * 129 * 3, "terrain positions size");
CHECK((int)tm.indices.size() == 128 * 128 * 6, "terrain index count");
// 平坦区顶点高度 = 1000 * 0.5 * 0.01 = 5.0 m
size_t o = (size_t(0) * 129 + 0) * 3;
CHECK(std::fabs(tm.positions[o + 1] - 5.0f) < 1e-4f, "terrain flat height 5m");
// 平坦区法线朝上
CHECK(std::fabs(tm.normals[o + 1] - 1.0f) < 1e-4f, "terrain flat normal up");
// 抬高点:1000 高度差 * 0.5 * 0.01 = 5m 峰
size_t p = (size_t(10) * 129 + 10) * 3;
CHECK(std::fabs(tm.positions[p + 1] - 10.0f) < 1e-4f, "terrain bump height 10m");
// 峰邻域法线不再朝正上
size_t q = (size_t(10) * 129 + 9) * 3;
CHECK(tm.normals[q + 1] < 0.999f, "terrain slope normal tilted");
// 区块原点:tile(1,2) 的 (0,0) 顶点应在 (128*2, _, 256*2) m
fmt::TerrainMesh tm2;
fmt::build_terrain_mesh(hm, 1, 2, 0.5, tm2);
CHECK(std::fabs(tm2.positions[0] - 256.0f) < 1e-3f, "terrain chunk origin X = tile_x*256");
CHECK(std::fabs(tm2.positions[2] - 512.0f) < 1e-3f, "terrain chunk origin Z = tile_y*256");
// GetHeight 三角插值:格心
double h = fmt::terrain_height_at(hm, 100.0, 100.0, 0.5); // cell (0,0), xdist=ydist=100
CHECK(std::fabs(h - 500.0) < 1.0, "terrain_height_at flat = 500cm");
}
static void test_splat() {
// 3 层棋盘:texel 值 1 / 2 / 3
fmt::TileMap tile;
const int S = fmt::SPLAT_RAW_XY;
tile.raw.assign(size_t(S) * S, 1);
for (int y = 0; y < S; ++y)
for (int x = 0; x < S; ++x) {
if (x > S / 2) tile.raw[y * S + x] = 2;
if (x > S / 2 && y > S / 2) tile.raw[y * S + x] = 3;
}
fmt::SplatSet ss;
fmt::build_splat(tile, 4, ss);
CHECK(ss.layers.size() == 3, "splat: 3 used layers");
CHECK(ss.layers[0].layer == 1 && ss.layers[2].layer == 3, "splat: layers sorted by index");
// 图层 1 在左半区应满覆盖
CHECK(ss.layers[0].alpha[10 * S + 10] == 0xFF, "splat L1 covers its region");
CHECK(ss.layers[0].alpha[10 * S + (S - 10)] == 0x00, "splat L1 zero outside (no bleed there)");
// 图层 2 在右半区覆盖;边界 1px 会羽化进 L3 区
CHECK(ss.layers[1].alpha[10 * S + (S - 10)] == 0xFF, "splat L2 covers its region");
CHECK(ss.layers[2].coverage > 0, "splat L3 has coverage");
// 未出现的图层不生成
fmt::TileMap flat;
flat.raw.assign(size_t(S) * S, 1);
fmt::SplatSet ss2;
fmt::build_splat(flat, 8, ss2);
CHECK(ss2.layers.size() == 1, "splat: only used layers emitted");
}
static void test_asset_resolver_norm() {
using fmt::AssetResolver;
CHECK(AssetResolver::normalize("d:\\ymir work\\Tree\\A.spt") == "ymir work/tree/a.spt",
"resolver normalize backslash+drive+case");
CHECK(AssetResolver::normalize("D:/ymir work/env/./x/../y.tga") == "ymir work/env/y.tga",
"resolver normalize . and ..");
CHECK(AssetResolver::normalize("/foo//bar") == "foo/bar", "resolver normalize // and lead /");
AssetResolver pack;
std::string error;
CHECK(pack.build_or_load("pack://", {}, &error), "40250 virtual pack root needs no directory scan");
CHECK(pack.resolve("D:\\Ymir Work\\Environment\\A.msenv") ==
"pack://d:/ymir work/environment/a.msenv", "pack lookup preserves drive prefix");
}
static void test_spt_tokens() {
std::string b;
const uint32_t h = 0;
const uint32_t n = 13;
b.append(reinterpret_cast<const char*>(&h), sizeof(h));
b.append(reinterpret_cast<const char*>(&n), sizeof(n));
b.append("__IdvSpt_02_", n);
b.push_back('\0');
b += "\\\\PagodaTreeBark.tga";
b.push_back('\0');
b += "CompositeShadowMapB1.tgaQF";
b.push_back('\0');
b += "CompositeMapB1#N";
fmt::SptInfo si;
CHECK(fmt::sniff_spt(b, si) && si.ok, "spt inline magic");
CHECK(si.composite_texture == "CompositeMapB1.dds", "spt composite atlas token");
CHECK(si.self_shadow_texture == "CompositeShadowMapB1.dds", "spt shadow atlas token");
}
// ---- live A1 断言(M2_ASSETS 设了才跑)----
static void test_live(const std::string& assets) {
const std::string map = assets + "/OutdoorA1/metin2_map_a1";
std::string err;
fmt::MapSetting s;
CHECK(fmt::parse_map_setting_file(map + "/setting.txt", s, &err),
("live setting.txt: " + err).c_str());
CHECK(s.map_size_x == 4 && s.map_size_y == 5, "live MapSize 4x5");
CHECK(s.chunk_count() == 20, "live 20 chunks");
// 20 个区块目录都在
int found = 0;
for (int tx = 0; tx < s.map_size_x; ++tx)
for (int ty = 0; ty < s.map_size_y; ++ty) {
fmt::HeightMap hm;
std::string e2;
if (fmt::load_height_map(map + "/" + fmt::m2coord::tile_dir(tx, ty) + "/height.raw",
hm, &e2))
++found;
else
std::fprintf(stderr, " tile %s: %s\n",
fmt::m2coord::tile_dir(tx, ty).c_str(), e2.c_str());
}
CHECK(found == 20, "live: all 20 height.raw load as 131x131");
fmt::TileMap tm;
CHECK(fmt::load_tile_map(map + "/000000/tile.raw", tm, &err),
("live tile.raw 258x258: " + err).c_str());
fmt::AttrMap am;
CHECK(fmt::load_attr_map(map + "/000000/attr.atr", am, &err),
("live attr.atr magic 2634: " + err).c_str());
fmt::WaterMap wm;
CHECK(fmt::load_water_map(map + "/000000/water.wtr", wm, &err),
("live water.wtr magic 5426: " + err).c_str());
// TextureSet:解析路径 textureset\metin2_A1.txt(大小写不敏感 -> 实盘小写)
fmt::TextureSet ts;
bool got_ts = fmt::parse_texture_set_file(
assets + "/textureset/textureset/metin2_a1.txt", ts, &err);
CHECK(got_ts, ("live textureset: " + err).c_str());
if (got_ts) CHECK(ts.layers.size() == 17, "live TextureSet 17 layers");
fmt::AreaData ad;
CHECK(fmt::parse_area_data_file(map + "/000000/areadata.txt", ad, &err),
("live areadata: " + err).c_str());
CHECK(ad.declared_count == 23, "live areadata ObjectCount 23");
CHECK(ad.objects.size() == 23, "live areadata parsed 23");
if (!ad.objects.empty())
CHECK(ad.objects[0].crc == 569394331u, "live areadata Object000 CRC 569394331");
// 坐标系:000000 在原点附近,x,y 幅度 < 一个区块(全局 == 本地在此区块巧合)
bool o000_ok = true;
for (auto& o : ad.objects)
if (o.x < 0 || o.x >= fmt::m2coord::CHUNK_CM || o.y > 0 || o.y <= -fmt::m2coord::CHUNK_CM)
o000_ok = false;
CHECK(o000_ok, "live 000000 areadata within chunk 0 (global cm, x>=0 y<=0)");
// 别的区块要落在该区块的全局范围 —— 001003 的 x 应 > 25600
fmt::AreaData ad13;
if (fmt::parse_area_data_file(map + "/001003/areadata.txt", ad13, &err) && !ad13.objects.empty()) {
bool shifted = false;
for (auto& o : ad13.objects)
if (o.x >= fmt::m2coord::CHUNK_CM) shifted = true;
CHECK(shifted, "live: 001003 objects are GLOBAL cm (x > 25600), not chunk-local");
}
// PARITY §3.7 —— 相邻区块共享边高度 / 顶点位置一致(无接缝)
{
fmt::HeightMap h00, h10;
std::string e3;
bool ok00 = fmt::load_height_map(map + "/000000/height.raw", h00, &e3);
bool ok10 = fmt::load_height_map(map + "/001000/height.raw", h10, &e3);
CHECK(ok00 && ok10, "seam: load 000000 + 001000 height.raw");
if (ok00 && ok10) {
int bad = 0;
for (int j = 0; j < fmt::TerrainMesh::VERTS_XY; ++j)
if (h00.at(128, j) != h10.at(0, j)) // 000000 右缘 vs 001000 左缘
++bad;
CHECK(bad == 0, "seam: 000000 col-128 heights == 001000 col-0 (border samples)");
fmt::TerrainMesh m00, m10;
fmt::build_terrain_mesh(h00, 0, 0, s.height_scale, m00);
fmt::build_terrain_mesh(h10, 1, 0, s.height_scale, m10);
const int N = fmt::TerrainMesh::VERTS_XY;
int pbad = 0;
for (int j = 0; j < N; ++j) {
size_t a = (size_t(j) * N + (N - 1)) * 3; // m00 右缘
size_t b = (size_t(j) * N + 0) * 3; // m10 左缘
for (int k = 0; k < 3; ++k)
if (std::fabs(m00.positions[a + k] - m10.positions[b + k]) > 1e-3f)
++pbad;
}
CHECK(pbad == 0, "seam: 000000/001000 shared-edge vertex positions match");
}
}
// .spt 嗅探(几何不可读,仅确认 magic + 抽贴图名 —— W4 结论 c 的程序化确认)
{
fmt::SptInfo si;
bool got = fmt::sniff_spt_file(
assets + "/Tree/ymir work/tree/b1_pagodatree_rt.spt", si);
CHECK(got && si.ok, "live .spt magic __IdvSpt");
CHECK(si.magic.rfind("__IdvSpt", 0) == 0, "live .spt magic string");
CHECK(!si.texture_refs.empty(), "live .spt has embedded texture refs");
CHECK(si.composite_texture == "CompositeMapB1.dds", "live .spt composite atlas");
CHECK(si.self_shadow_texture == "CompositeShadowMapB1.dds", "live .spt shadow atlas");
}
// Environment:A1.msenv 由虚拟路径解析
fmt::Environment env;
CHECK(fmt::parse_environment_file(assets + "/ETC/ymir work/environment/a1.msenv", env, &err),
("live a1.msenv: " + err).c_str());
CHECK(env.script_type == "EnvrionmentData", "live msenv script_type");
CHECK(env.fog.enable && env.sky.gradient.size() >= 8, "live msenv fog + gradient list");
fmt::Environment trent;
CHECK(fmt::parse_environment_file(
assets + "/season2/season2/metin2_map_trent02_a/trent02.msenv", trent, &err),
("live trent02.msenv: " + err).c_str());
CHECK(trent.dir_light.ch_enable && trent.dir_light.ch_ambient[0] > 0.39f,
"live trent character light");
CHECK(!trent.filter.enable && trent.filter.alpha_src == 2 && trent.filter.alpha_dest == 2,
"live trent filter fields");
CHECK(trent.sky.cloud_color.size() == 2 && trent.lens_flare.enable,
"live trent cloud gradient + lens flare");
// Property CRC 注册表
fmt::PropertyRegistry reg;
CHECK(reg.scan(assets + "/Property", &err), ("live Property scan: " + err).c_str());
CHECK(reg.by_crc.size() > 1000, "live Property registry > 1000 entries");
const fmt::Property* p0 = reg.find(569394331u);
CHECK(p0 != nullptr, "live Property CRC 569394331 resolves");
if (p0) CHECK(p0->type == fmt::PropertyType::Tree, "live CRC 569394331 = Tree (Pagoda1)");
// areadata 的每个 CRC 都能在注册表命中
int miss = 0;
for (auto& o : ad.objects)
if (!reg.find(o.crc)) ++miss;
CHECK(miss == 0, "live: every 000000 areadata CRC found in Property registry");
// AssetResolver
fmt::AssetResolver res;
CHECK(res.build(assets, fmt::AssetResolver::default_priority(), &err),
("live AssetResolver build: " + err).c_str());
CHECK(res.files_indexed > 10000, "live resolver indexed > 10000 files");
std::string r;
CHECK(!res.resolve("d:/ymir work/environment/clouds_zone01.tga", &r).empty(),
("live resolve cloud tga: " + r).c_str());
CHECK(!res.resolve("d:\\ymir work\\tree\\b1_pagodatree_rt.spt", &r).empty(),
("live resolve pagoda spt: " + r).c_str());
CHECK(res.resolve("d:/ymir work/does/not/exist.xxx", &r).empty(),
"live resolve missing -> empty");
// TextureSet 里的图层贴图路径应可解析
int tex_miss = 0;
for (auto& L : ts.layers)
if (res.resolve(L.texture, nullptr).empty()) ++tex_miss;
CHECK(tex_miss == 0, "live: all TextureSet layer textures resolve");
}
int main() {
test_tokvec();
test_coord();
test_map_setting();
test_texture_set();
test_area_data();
test_environment();
test_property();
test_terrain_mesh();
test_asset_resolver_norm();
test_spt_tokens();
if (const char* a = std::getenv("M2_ASSETS"); a && *a) {
std::fprintf(stderr, "-- live A1 asserts (M2_ASSETS=%s) --\n", a);
test_live(a);
} else {
std::fprintf(stderr, "-- M2_ASSETS unset: skipping live A1 asserts --\n");
}
if (g_fail) {
std::fprintf(stderr, "%d check(s) failed\n", g_fail);
return 1;
}
std::fprintf(stderr, "all checks passed\n");
return 0;
}
-347
View File
@@ -1,347 +0,0 @@
#include <msa.h>
#include <cmath>
#include <cstdio>
#include <string>
int main() {
{
fmt::Msa effects;
std::string error;
if (!fmt::parse_msa(R"(MotionFileName "test.gr2"
Group MotionEventData
{
Group Event00
{
MotionEventType 1
StartingTime 0.25
EffectFileName "d:/ymir work/pc/test.mse"
AttachingBoneName "equip_right_hand"
AttachingEnable 1
FollowingEnable 1
IndependentFlag 1
EffectPosition 10 20 30
}
}
)", effects, &error) || effects.events.size() != 1 ||
!effects.events[0].attaching || !effects.events[0].following ||
!effects.events[0].independent || effects.events[0].attaching_bone != "equip_right_hand" ||
effects.events[0].position[2] != 30) {
std::fprintf(stderr, "effect attachment metadata lost\n");
return 1;
}
}
{
// 40250 RaceMotionDataEvent has dedicated payloads for FLY,
// SCREEN_WAVING and SPECIAL_ATTACKING. Keep the no-payload event
// types in the same snapshot so they cannot be silently dropped.
const std::string events = R"(MotionFileName "events.gr2"
Group MotionEventData
{
Group Event00
{
MotionEventType 2
StartingTime 0.10
DuringTime 0.20
Power 300
AffectingRange 200
}
Group Event01
{
MotionEventType 4
StartingTime 0.30
DuringTime 0.40
EnableHitProcess 0
AttackType 1
HittingType 2
StiffenTime 0.05
InvisibleTime 0.10
ExternalForce 20
HitLimitCount 3
CollisionType 4
SphereDataCount 2
Group SphereData00
{
Radius 100
Position 1 2 3
}
Group SphereData01
{
Radius 200
Position 4 5 6
}
}
Group Event02
{
MotionEventType 6
StartingTime 0.50
AttachingEnable 1
AttachingBoneName "Bip01 R Hand"
FlyFileName "d:/ymir work/pc/warrior/effect/geompung.msf"
FlyPosition 7 8 9
}
Group Event03
{
MotionEventType 7
StartingTime 0.60
}
Group Event04
{
MotionEventType 8
StartingTime 0.70
}
Group Event05
{
MotionEventType 9
StartingTime 0.80
}
}
)";
fmt::Msa parsed;
std::string error;
if (!fmt::parse_msa(events, parsed, &error) || parsed.events.size() != 6) {
std::fprintf(stderr, "motion event payload parse failed: %s\n", error.c_str());
return 1;
}
const fmt::MotionEvent *wave = &parsed.events[0];
const fmt::MotionEvent *attack = &parsed.events[1];
const fmt::MotionEvent *fly = &parsed.events[2];
if (wave->type != 2 || std::fabs(wave->duration_time - 0.2f) > 1e-6f ||
wave->power != 300 || wave->affecting_range != 200 ||
attack->type != 4 || attack->enable_hit_process || attack->attack_type != 1 ||
attack->hitting_type != 2 || attack->collision_type != 4 ||
attack->collision_spheres.size() != 2 ||
std::fabs(attack->collision_spheres[1].radius - 200.0f) > 1e-6f ||
attack->collision_spheres[1].position[2] != 6.0f ||
fly->type != 6 || fly->fly_file.find("geompung.msf") == std::string::npos ||
!fly->fly_attaching || fly->fly_attaching_bone != "Bip01 R Hand" ||
fly->fly_position[0] != 7.0f || parsed.events[3].type != 7 ||
parsed.events[4].type != 8 || parsed.events[5].type != 9) {
std::fprintf(stderr, "40250 motion event payload fields were lost\n");
return 1;
}
}
const std::string text = R"(
ScriptType MotionData
MotionFileName "d:/ymir work/pc/test.gr2"
MotionDuration 2.0
Group LoopData
{
MotionLoopCount 2
LoopCancelEnable 1
LoopStartTime 0.25
LoopEndTime 1.75
}
)";
fmt::Msa msa;
std::string err;
if (!fmt::parse_msa(text, msa, &err)) {
std::fprintf(stderr, "parse failed: %s\n", err.c_str());
return 1;
}
const bool ok = msa.has_loop_data && msa.motion_loop_count == 2 &&
msa.loop_cancel_enable && std::fabs(msa.loop_start_time - 0.25f) < 1e-6f &&
std::fabs(msa.loop_end_time - 1.75f) < 1e-6f;
if (!ok) {
std::fprintf(stderr, "LoopData fields were not preserved\n");
return 1;
}
// A motion with no ComboInputData / AttackingData leaves the flags clear.
if (msa.has_combo_input || msa.has_attacking_data) {
std::fprintf(stderr, "combat groups reported present when absent\n");
return 1;
}
// CLIENT-GAP §3.3 / §3.5 —— real onehand_sword/combo_01.msa field values.
const std::string combo = R"(
ScriptType MotionData
MotionFileName "d:/ymir work/pc/warrior/onehand_sword/combo_01.gr2"
MotionDuration 1.000000
Accumulation 0.00 -131.76 0.00
Group ComboInputData
{
PreInputTime 0.167094
DirectInputTime 0.533333
InputLimitTime 0.602564
LinkTime 0.058889
}
Group AttackingData
{
AttackingType 2
HittingType 2
StiffenTime 0.0
InvisibleTime 0.100000
ExternalForce 8.000000
AttackingStartTime 0.192308
AttackingEndTime 0.315385
}
)";
fmt::Msa cm;
if (!fmt::parse_msa(combo, cm, &err)) {
std::fprintf(stderr, "combo parse failed: %s\n", err.c_str());
return 1;
}
const bool combo_ok = cm.has_combo_input &&
std::fabs(cm.combo_pre_input_time - 0.167094f) < 1e-5f &&
std::fabs(cm.combo_direct_input_time - 0.533333f) < 1e-5f &&
std::fabs(cm.combo_input_limit_time - 0.602564f) < 1e-5f &&
std::fabs(cm.combo_link_time - 0.058889f) < 1e-5f;
const bool atk_ok = cm.has_attacking_data && cm.attacking_type == 2 && cm.hitting_type == 2 &&
std::fabs(cm.attack_start_time - 0.192308f) < 1e-5f &&
std::fabs(cm.attack_end_time - 0.315385f) < 1e-5f &&
std::fabs(cm.invisible_time - 0.1f) < 1e-5f &&
std::fabs(cm.external_force - 8.0f) < 1e-5f;
if (!combo_ok || !atk_ok) {
std::fprintf(stderr, "ComboInputData / AttackingData fields were not preserved\n");
return 1;
}
// §3.5 修改 1 —— 老式内联写法(无 HitDataCount):单个命中窗,含 List HitPosition 采样。
const std::string inl = R"(
ScriptType MotionData
MotionFileName "d:/ymir work/pc/warrior/general/attack.gr2"
MotionDuration 1.0
Group AttackingData
{
AttackingType 1
HittingType 2
StiffenTime 0.0
InvisibleTime 0.5
ExternalForce 0.0
AttackingStartTime 0.456410
AttackingEndTime 0.597436
AttackingBone "equip_right_hand"
WeaponLength 0.0
List HitPosition
{
0.456410 1.0 2.0 3.0 4.0 5.0 6.0
0.464744 7.0 8.0 9.0 10.0 11.0 12.0
}
}
)";
fmt::Msa im;
if (!fmt::parse_msa(inl, im, &err)) {
std::fprintf(stderr, "inline attacking parse failed: %s\n", err.c_str());
return 1;
}
if (im.hit_windows.size() != 1 || im.hit_windows[0].samples.size() != 2 ||
im.hit_windows[0].bone_name != "equip_right_hand" ||
std::fabs(im.hit_windows[0].start_time - 0.456410f) > 1e-5f ||
std::fabs(im.hit_windows[0].end_time - 0.597436f) > 1e-5f ||
std::fabs(im.attack_start_time - 0.456410f) > 1e-5f ||
std::fabs(im.hit_windows[0].samples[1].time - 0.464744f) > 1e-5f ||
std::fabs(im.hit_windows[0].samples[1].last_pos[0] - 7.0f) > 1e-5f ||
std::fabs(im.hit_windows[0].samples[1].pos[2] - 12.0f) > 1e-5f) {
std::fprintf(stderr, "inline HitData window / HitPosition samples not preserved\n");
return 1;
}
// §3.5 修改 1 —— 新式写法:AttackType + MotionType + HitDataCount N + Group HitData0N。
const std::string multi = R"(
ScriptType MotionData
MotionFileName "d:/ymir work/pc/warrior/twohand_sword/combo_05.gr2"
MotionDuration 1.6
Group AttackingData
{
AttackType 0
HittingType 2
StiffenTime 0.0
InvisibleTime 0.2
ExternalForce 5.0
HitLimitCount 3
MotionType 2
HitDataCount 2
Group HitData00
{
AttackingStartTime 0.455475
AttackingEndTime 0.759124
AttackingBone "equip_right_hand"
WeaponLength 150.0
List HitPosition
{
0.455475 54.5 -30.5 71.1 89.9 105.7 19.4
}
}
Group HitData01
{
AttackingStartTime 0.9
AttackingEndTime 1.1
AttackingBone "equip_left_hand"
WeaponLength 120.0
}
}
)";
fmt::Msa mm;
if (!fmt::parse_msa(multi, mm, &err)) {
std::fprintf(stderr, "multi HitData parse failed: %s\n", err.c_str());
return 1;
}
if (mm.attacking_type != 0 || mm.motion_type != 2 || mm.hit_limit_count != 3 ||
mm.hit_windows.size() != 2 ||
std::fabs(mm.hit_windows[0].start_time - 0.455475f) > 1e-5f ||
mm.hit_windows[0].samples.size() != 1 ||
mm.hit_windows[1].bone_name != "equip_left_hand" ||
std::fabs(mm.hit_windows[1].end_time - 1.1f) > 1e-5f ||
mm.hit_windows[1].samples.size() != 0 ||
std::fabs(mm.attack_start_time - 0.455475f) > 1e-5f) {
std::fprintf(stderr, "multi HitData windows not preserved\n");
return 1;
}
// ---- 可选:真实 PC .msa(设了 M2_ASSETS 才跑)----------------------------
if (const char* root = std::getenv("M2_ASSETS")) {
const std::string base = std::string(root) + "/PC/ymir work/pc/warrior/";
fmt::Msa a1;
if (fmt::parse_msa_file(base + "general/attack.msa", a1, &err)) {
if (a1.hit_windows.size() != 1 || a1.hit_windows[0].bone_name != "equip_right_hand" ||
std::fabs(a1.hit_windows[0].start_time - 0.456410f) > 1e-4f ||
std::fabs(a1.hit_windows[0].end_time - 0.597436f) > 1e-4f ||
a1.hit_windows[0].samples.size() != 17) {
std::fprintf(stderr, "live general/attack.msa window/samples wrong "
"(windows=%zu samples=%zu)\n", a1.hit_windows.size(),
a1.hit_windows.empty() ? 0 : a1.hit_windows[0].samples.size());
return 1;
}
}
fmt::Msa c5;
if (fmt::parse_msa_file(base + "twohand_sword/combo_05.msa", c5, &err)) {
if (c5.hit_windows.size() != 1 || c5.motion_type != 2 ||
std::fabs(c5.hit_windows[0].weapon_length - 150.0f) > 1e-3f ||
std::fabs(c5.hit_windows[0].start_time - 0.455475f) > 1e-4f ||
c5.hit_windows[0].samples.size() != 37) {
std::fprintf(stderr, "live twohand_sword/combo_05.msa wrong "
"(windows=%zu samples=%zu)\n", c5.hit_windows.size(),
c5.hit_windows.empty() ? 0 : c5.hit_windows[0].samples.size());
return 1;
}
}
// 真正的双持双命中窗(右手窗 + 左手窗)。
const std::string adh = std::string(root) + "/PC/ymir work/pc/assassin/dualhand_sword/combo_01.msa";
fmt::Msa dh;
if (fmt::parse_msa_file(adh, dh, &err)) {
if (dh.hit_windows.size() != 2 ||
dh.hit_windows[0].bone_name != "Bip01 R Hand" ||
dh.hit_windows[1].bone_name != "Bip01 L Hand" ||
dh.hit_windows[0].samples.size() != 13 ||
dh.hit_windows[1].samples.size() != 13 ||
std::fabs(dh.hit_windows[1].start_time - 0.605128f) > 1e-4f) {
std::fprintf(stderr, "live assassin dualhand combo_01.msa two-window wrong "
"(windows=%zu)\n", dh.hit_windows.size());
return 1;
}
}
fmt::Msa pb;
if (fmt::parse_msa_file(base + "skill/palbang.msa", pb, &err)) {
// AttackingData 后还有 Group MotionEventData —— List 修复后二者都要解析到。
if (pb.hit_windows.size() != 1 || pb.hit_limit_count != 18 ||
std::fabs(pb.hit_windows[0].weapon_length - 130.0f) > 1e-3f ||
pb.events.size() != 3) {
std::fprintf(stderr, "live skill/palbang.msa wrong "
"(windows=%zu events=%zu)\n", pb.hit_windows.size(), pb.events.size());
return 1;
}
}
}
return 0;
}
-84
View File
@@ -1,84 +0,0 @@
// .msm HairData: SourceSkin -> TargetSkin recolour catalogue (PARITY §2.4).
#include <msm.h>
#include <cstdio>
#include <string>
int main() {
// Shape of assets/root/msm/warrior_m.msm HairData.
const std::string text = R"(
ScriptType RaceDataScript
BaseModelFileName "d:/ymir work/pc2/warrior/warrior_novice.GR2"
MotionListFileName "motlist_range.txt"
Group HairData
{
PathName "d:/ymir Work/pc2/warrior/"
HairDataCount 3
Group HairData00
{
HairIndex 0
Model "hair/hair_1_1.gr2"
SourceSkin "hair/hair_1_1.dds"
TargetSkin "warrior_hair_01.dds"
}
Group HairData01
{
HairIndex 3
Model "hair/hair_1_1.gr2"
SourceSkin "hair/hair_1_1.dds"
TargetSkin "warrior_hair_01_red.dds"
}
Group HairData02
{
HairIndex 1001
Model "hair/hair_2_1.gr2"
SourceSkin "hair/hair_2_1.dds"
TargetSkin "warrior_hair_02_gold.dds"
}
}
)";
fmt::Msm m;
std::string err;
if (!fmt::parse_msm(text, m, &err)) {
std::fprintf(stderr, "parse failed: %s\n", err.c_str());
return 1;
}
int fails = 0;
auto check = [&](bool c, const char *msg) {
if (!c) {
std::fprintf(stderr, "FAIL: %s\n", msg);
++fails;
}
};
check(m.base_model_gr2 == "d:/ymir work/pc2/warrior/warrior_novice.GR2" ||
m.base_model_gr2 == "d:/ymir work/pc2/warrior/warrior_novice.gr2",
"base model gr2");
check(m.motion_list_file == "motlist_range.txt", "motion list file");
check(m.hair_path == "d:/ymir Work/pc2/warrior/" || m.hair_path == "d:/ymir work/pc2/warrior/",
"hair PathName");
check(m.hairs.size() == 3, "3 hair entries");
if (m.hairs.size() == 3) {
check(m.hairs[0].index == 0, "entry0 HairIndex");
check(m.hairs[0].model == "hair/hair_1_1.gr2", "entry0 Model");
check(m.hairs[0].source_skin == "hair/hair_1_1.dds", "entry0 SourceSkin");
check(m.hairs[0].target_skin == "warrior_hair_01.dds", "entry0 TargetSkin");
// same gr2 + SourceSkin, different TargetSkin -> the recolour case
check(m.hairs[1].model == m.hairs[0].model, "entry1 shares gr2");
check(m.hairs[1].source_skin == m.hairs[0].source_skin, "entry1 shares SourceSkin");
check(m.hairs[1].target_skin == "warrior_hair_01_red.dds", "entry1 TargetSkin differs");
check(m.hairs[1].index == 3, "entry1 HairIndex");
check(m.hairs[2].model == "hair/hair_2_1.gr2", "entry2 Model");
check(m.hairs[2].target_skin == "warrior_hair_02_gold.dds", "entry2 TargetSkin");
}
if (fails) {
std::fprintf(stderr, "%d check(s) failed\n", fails);
return 1;
}
std::printf("all checks passed\n");
return 0;
}
-129
View File
@@ -1,129 +0,0 @@
// M2 T1 —— Metin2 文本脚本 token 树解析。
#include "textscript.h"
#include "m2_tokvec.h" // fmt::read_file (host-injectable; res:// on mobile)
#include <cctype>
#include <cstdio>
#include <cstdlib>
#include <cstring>
namespace fmt {
namespace {
bool ieq(const std::string& a, const char* b) {
if (a.size() != std::strlen(b)) return false;
for (size_t i = 0; i < a.size(); ++i)
if (std::tolower((unsigned char)a[i]) != std::tolower((unsigned char)b[i])) return false;
return true;
}
// tokenize 一行为止:支持 "quoted"(含空白)、# 注释
std::vector<std::string> tokenize_all(const std::string& text) {
std::vector<std::string> toks;
size_t i = 0, n = text.size();
while (i < n) {
char c = text[i];
if (c == '#' || (c == '/' && i + 1 < n && text[i + 1] == '/')) { // 行注释
while (i < n && text[i] != '\n') ++i;
continue;
}
if (std::isspace((unsigned char)c)) { ++i; continue; }
if (c == '{' || c == '}') { toks.emplace_back(1, c); ++i; continue; }
if (c == '"') {
++i; std::string s;
while (i < n && text[i] != '"') s += text[i++];
if (i < n) ++i; // 跳过右引号
toks.push_back(s);
continue;
}
std::string s;
while (i < n && !std::isspace((unsigned char)text[i]) && text[i] != '{' && text[i] != '}')
s += text[i++];
toks.push_back(s);
}
return toks;
}
// 递归解析一个 Group 体(已消费到 '{' 之后;到匹配的 '}' 或流尾停)
bool parse_body(const std::vector<std::string>& t, size_t& i, Node& node, std::string* err) {
while (i < t.size()) {
const std::string& tok = t[i];
if (tok == "}") { ++i; return true; }
// `Group Name { ... }` 和 `List Name { ... }`(CTextFileLoader 的 GetTokenVector)
// 都当子节点:Group 体是 key/子组,List 体是一串裸值(收进子节点的单行)。
if (ieq(tok, "Group") || ieq(tok, "List")) {
const char* kind = ieq(tok, "Group") ? "Group" : "List";
if (i + 2 >= t.size()) { if (err) *err = std::string(kind) + " 缺名字 / {"; return false; }
Node sub;
sub.name = t[i + 1];
if (t[i + 2] != "{") { if (err) *err = std::string(kind) + " '" + sub.name + "' 后缺 {"; return false; }
i += 3;
if (!parse_body(t, i, sub, err)) return false;
node.groups.push_back(std::move(sub));
continue;
}
// 普通行:从 tok 起,直到下一个会开启新语义的 token(Group / } / 或另一行的 key)
// .msa/.msm 是「一行一 key」,我们靠换行还原不了(已丢),改用:
// key + 其后所有非 Group/非} token 直到遇到一个「像 key 的」…太脆。
// 实际这些文件每行 = key + 定长值,值都是数字 / 带引号串 / 简单标识。
// 简化:把连续的 token 收进当前行,遇到 Group/} 或「首字母大写且后面还跟 Group 结构」时断。
// 经验规则:值不会是 "Group";key 之后收 1..N 个值,遇到下一个满足「不是纯数字、不是带过引号」
// 的裸标识就当新行开始。对 dance_1.msa / warrior_w.msm 足够。
std::vector<std::string> line;
line.push_back(t[i++]);
while (i < t.size()) {
const std::string& x = t[i];
if (x == "}" || ieq(x, "Group")) break;
// 判断 x 是否是「新行的 key」:不含数字开头、不含 '.'、'/'、'\\'、'"' 迹象,
// 且当前行已至少有 1 个值
bool looks_key = line.size() >= 2 &&
(std::isalpha((unsigned char)x[0]) || x[0] == '_') &&
x.find_first_of("/\\.:") == std::string::npos;
// 数字 / 负号 / 小数一律当值
bool is_num = !x.empty() && (x[0] == '-' || x[0] == '+' || x[0] == '.' ||
std::isdigit((unsigned char)x[0]));
if (looks_key && !is_num) break;
line.push_back(x);
++i;
}
node.lines.push_back(std::move(line));
}
return true; // 流尾(根节点)
}
} // namespace
const std::vector<std::string>* Node::find(const char* key) const {
for (const auto& l : lines) if (!l.empty() && ieq(l[0], key)) return &l;
return nullptr;
}
std::string Node::str(const char* key, const std::string& def) const {
auto* l = find(key);
return (l && l->size() >= 2) ? (*l)[1] : def;
}
float Node::num(const char* key, float def) const {
auto* l = find(key);
return (l && l->size() >= 2) ? float(std::atof((*l)[1].c_str())) : def;
}
int Node::inum(const char* key, int def) const {
auto* l = find(key);
return (l && l->size() >= 2) ? std::atoi((*l)[1].c_str()) : def;
}
const Node* Node::group(const char* name) const {
for (const auto& g : groups) if (ieq(g.name, name)) return &g;
return nullptr;
}
bool parse_textscript(const std::string& text, Node& root, std::string* err) {
root = Node{};
auto toks = tokenize_all(text);
size_t i = 0;
return parse_body(toks, i, root, err);
}
bool parse_textscript_file(const std::string& path, Node& root, std::string* err) {
std::string s;
if (!read_file(path, s)) { if (err) *err = "cannot open " + path; return false; }
return parse_textscript(s, root, err);
}
} // namespace fmt
-27
View File
@@ -1,27 +0,0 @@
// Metin2 文本脚本(.msa / .msm / 各种 .txt)—— token 树解析。
// 语法:空白分隔 token;`key value...` 行;`Group Name { ... }` 块;`"quoted"` 串;`#` 到行尾注释。
// 照 EterBase/CTextFileLoader + EterLib CTokenVector 的效果。M2 T1。
#pragma once
#include <string>
#include <vector>
namespace fmt {
struct Node {
std::string name; // Group 名(根节点为空)
std::vector<std::vector<std::string>> lines; // 非 Group 行:token 列表([0]=key)
std::vector<Node> groups; // 子 Group
// 便捷取值(在本节点的 lines 里找 key,大小写不敏感)
const std::vector<std::string>* find(const char* key) const;
std::string str(const char* key, const std::string& def = "") const; // 取第 1 个值(去引号)
float num(const char* key, float def = 0) const;
int inum(const char* key, int def = 0) const;
const Node* group(const char* name) const;
};
// 解析整段文本。失败返回 false 并写 err。
bool parse_textscript(const std::string& text, Node& root, std::string* err);
bool parse_textscript_file(const std::string& path, Node& root, std::string* err);
} // namespace fmt
-66
View File
@@ -1,66 +0,0 @@
#include "texture_set.h"
#include "m2_tokvec.h"
#include <cstdio>
#include <cstdlib>
namespace fmt {
bool parse_texture_set(const std::string& text, TextureSet& out, std::string* err) {
TokVecMap tv;
std::string perr;
parse_tokvec(text, tv, &perr);
out = TextureSet{};
if (!tv.has("textureset")) {
if (err) *err = "textureset 缺首行 `TextureSet`";
return false;
}
if (!tv.has("texturecount")) {
if (err) *err = "textureset 缺 TextureCount";
return false;
}
out.declared_count = (int)tv.inum("texturecount");
if (out.declared_count < 0) {
if (err) *err = "textureset TextureCount 非法";
return false;
}
for (int i = 1; i <= out.declared_count; ++i) {
char key[32];
std::snprintf(key, sizeof(key), "texture%03d", i);
const auto* v = tv.find(key);
if (!v) {
if (err) *err = std::string("textureset 缺块 ") + key;
return false;
}
if (v->size() < 8) {
if (err) *err = std::string("textureset 块 ") + key + " token 不足 8 个";
return false;
}
TextureLayer L;
L.texture = (*v)[0];
L.u_scale = (float)std::atof((*v)[1].c_str());
L.v_scale = (float)std::atof((*v)[2].c_str());
L.u_offset = (float)std::atof((*v)[3].c_str());
L.v_offset = (float)std::atof((*v)[4].c_str());
L.splat_enabled = std::atoi((*v)[5].c_str()) != 0;
L.height_begin = (unsigned short)std::atoi((*v)[6].c_str());
L.height_end = (unsigned short)std::atoi((*v)[7].c_str());
out.layers.push_back(std::move(L));
}
return true;
}
bool parse_texture_set_file(const std::string& path, TextureSet& out, std::string* err) {
std::string text;
if (!read_file(path, text)) {
if (err) *err = "打不开 " + path;
return false;
}
return parse_texture_set(text, out, err);
}
} // namespace fmt
-31
View File
@@ -1,31 +0,0 @@
// TextureSet .txt(首行 `TextureSet`)—— 地表图层表。
// 对标 PRTerrainLib/TextureSet.cpp CTextureSet::Load。SHINSOO §5.4。
//
// 关键:运行时 index 0 是原客户端内部的空图层(AddEmptyTexture),文件里的
// `Texture001` 对应运行时索引 1。`layers` 按文件顺序(即运行时索引 1..N)。
#pragma once
#include <string>
#include <vector>
namespace fmt {
struct TextureLayer {
std::string texture; // 相对路径(原样,含 d:\ymir work\ 前缀)
float u_scale = 1, v_scale = 1;
float u_offset = 0, v_offset = 0;
bool splat_enabled = false;
unsigned short height_begin = 0;
unsigned short height_end = 0;
};
struct TextureSet {
int declared_count = 0; // TextureCount
std::vector<TextureLayer> layers; // 实际解析到的图层(运行时索引 = 下标 + 1)
int runtime_count() const { return (int)layers.size() + 1; } // +1 空图层
};
bool parse_texture_set(const std::string& text, TextureSet& out, std::string* err);
bool parse_texture_set_file(const std::string& path, TextureSet& out, std::string* err);
} // namespace fmt
+1 -1
View File
@@ -17,7 +17,7 @@
## 验证
- 全量 9166 个 Metin2 `.gr2`:**0 崩溃、0 非退化谓词失败**(`tools/gr2fuzz`)
- **对拍真 Granny 2.9.12**:骨骼世界矩阵 ≤ 4.6e-5、蒙皮顶点 ≤ 6.5e-5(`tools/oracle_diff`,`test/m2-numeric.json`,23 用例含 v7 / 双 root / 刚体 / 多动画帧)
- **对拍真 Granny 2.9.12**:骨骼世界矩阵 ≤ 4.6e-5、蒙皮顶点 ≤ 6.5e-5(`tools/oracle_diff`,`oracle/m2-numeric.json`,23 用例含 v7 / 双 root / 刚体 / 多动画帧)
- 坏输入(空 / 错 magic / 截断 / 越界索引)一律干净报错,不崩
## 明确不做(Metin2 语料里 0 出现;要「任意 gr2」才需要)
+3 -3
View File
@@ -1,12 +1,12 @@
#!/usr/bin/env bash
# M2 门禁·主:对测试资产集跑 oracle dump + oracle_diff,汇总 test/m2-numeric.json。
# M2 门禁·主:对测试资产集跑 oracle dump + oracle_diff,汇总 oracle/m2-numeric.json。
# 前置:oracle/build-wine.sh 已产出 oracle.exe;WINEPREFIX 已 init(见 RUNBOOK.md);
# oracle_diff 已构建(cmake -B build-tools -DMT_BUILD_TOOLS=ON)。
set -uo pipefail
cd "$(dirname "$0")/.."
REPO="$(pwd)"
ASSETS="$(cd "${MT_ASSETS:-$REPO/assets}" && pwd)"
DUMPS="$REPO/test/oracle_dumps"
DUMPS="$REPO/build/oracle_dumps"
DIFF=""
for c in "$REPO/build-tools/tools/oracle_diff" "$REPO/build/tools/oracle_diff"; do
[ -x "$c" ] && DIFF="$c" && break
@@ -72,7 +72,7 @@ wait
# ── 2. diff + 汇总 ─────────────────────────────────────────────────────
echo "== oracle_diff =="
JSON="$REPO/test/m2-numeric.json"
JSON="$REPO/oracle/m2-numeric.json"
{
echo '{'
echo ' "granny": "2.9.12.0",'
-12
View File
@@ -1,12 +0,0 @@
# Test data
`golden/` and `bgfx-reference/` contain historical reference screenshots. `assets.list` and `assets-good.txt` list asset samples; `m2-numeric.json` records Granny oracle comparisons.
Build the asset tools without a UI engine:
```sh
cmake -S . -B build-tools -DMT_BUILD_TOOLS=ON
cmake --build build-tools -j8
```
The active 40250 port tests run through `script/port_gate.sh macos`. The native SDL/Vulkan client has its own acceptance runner, `script/native_mac_acceptance.mjs`.
-9166
View File
File diff suppressed because it is too large Load Diff
-35
View File
@@ -1,35 +0,0 @@
# 测试资产集 —— 由 M0 T9c 从 fuzz 报告 + 人工挑产出。
# 路径相对 XRENDER_ASSET_ROOT。每行一个。带 # 的是注释。
# 覆盖:刚体 / 单材质蒙皮 / 多材质+alpha / 多部件装配+挂点 / 高骨骼数 / 全 4 级 LOD /
# 多 stage 材质(MODULATE2X 或 ADDSIGNED) / 特效挂点 / fuzz 异常个例
#
# ── bootstrap 引导集(M0 自身验证用,assets.list 产出前)──
PC/ymir work/pc/warrior/warrior_cheongrin.gr2
PC/ymir work/pc/warrior/warrior_cheongrin_lod_01.gr2
PC/ymir work/pc/warrior/warrior_cheongrin_lod_02.gr2
PC/ymir work/pc/warrior/warrior_cheongrin_lod_03.gr2
PC/ymir work/pc/warrior/action/dance_1.gr2
Zone/ymir work/zone/oxevent/ox_01.gr2
#
# ── 正式集:M0 T9c 补(fuzz 全量 9166 零崩溃 / 零谓词失败后精选)──
#
# 蒙皮角色 + 全 4 级 LOD(body/face/附件 5 mesh,PNT332_Skinned,75 骨 Bip01)
# base 已在 bootstrap;补:另一套 warrior 全 LOD 用于跨模型回归
PC/ymir work/pc/warrior/warrior_lord.gr2
PC/ymir work/pc/warrior/warrior_lord_lod_01.gr2
PC/ymir work/pc/warrior/warrior_lord_lod_02.gr2
PC/ymir work/pc/warrior/warrior_lord_lod_03.gr2
# 高 mesh 数装配(17 mesh,6 材质 / 3 贴图)—— 顶点类型混合 + 多 stage 材质
PC/ymir work/pc/assassin/assassin.gr2
# 多 skeleton(本体 + 2 武器挂点,3 skeleton / 3 model)—— bone-binding 跨 skeleton 解析
metin2_patch_dragon_rock_mobs/ymir work/monster2/redthief2_soldier2/redthief2_soldier2_lod_01.gr2
# 刚体 mesh(PNT332,rigid=1)—— 怪物 LOD
monster2/ymir work/monster2/snakeman_bow/snakeman_bow_lod_01.gr2
# 动画:曲线子类型齐全(pos d0/d2·dim3、rot d0/d2·dim4、scale d0·dim9 + d1·dim9)
PC/ymir work/pc/warrior/action/dance_1.gr2
monster2/ymir work/monster2/snakeman_bow/00.gr2
# format v7(Std32LE magic,非 GRNFileMV_Old)—— 11/9166;含 PC 与场景
PC/ymir work/pc/shaman/shaman_lord.gr2
Zone/ymir work/zone/dungeon/snow_dungeon/dungeon_a1.gr2
# 异常个例:bind-pose 自洽 >= 1e-1 的 4 个之一(root 偏移未落在 local/IP 链上)
metin2_patch_easter1/ymir work/pc/warrior/warrior_rabbit1_backup.gr2
Binary file not shown.

Before

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 300 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 290 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 351 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 300 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 368 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 254 KiB

Some files were not shown because too many files have changed in this diff Show More