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>
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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」才需要)
|
||||
|
||||
@@ -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",'
|
||||
|
||||
@@ -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`.
|
||||
@@ -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
|
||||
|
Before Width: | Height: | Size: 240 KiB |
|
Before Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 243 KiB |
|
Before Width: | Height: | Size: 300 KiB |
|
Before Width: | Height: | Size: 290 KiB |
|
Before Width: | Height: | Size: 181 KiB |
|
Before Width: | Height: | Size: 256 KiB |
|
Before Width: | Height: | Size: 188 KiB |
|
Before Width: | Height: | Size: 351 KiB |
|
Before Width: | Height: | Size: 240 KiB |
|
Before Width: | Height: | Size: 300 KiB |
|
Before Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 240 KiB |
|
Before Width: | Height: | Size: 368 KiB |
|
Before Width: | Height: | Size: 176 KiB |
|
Before Width: | Height: | Size: 247 KiB |
|
Before Width: | Height: | Size: 240 KiB |
|
Before Width: | Height: | Size: 250 KiB |
|
Before Width: | Height: | Size: 250 KiB |
|
Before Width: | Height: | Size: 258 KiB |
|
Before Width: | Height: | Size: 254 KiB |