Metin2 game client (P0–P11) + mobile asset pipeline

Networked client on the existing Godot 4.7 + libgr2 renderer:
- net: m2dev wire protocol (libsodium KX + XChaCha20), auth/select/game
  phases, EntityStore world model, ~all GC/CG headers. char create/delete,
  private shop / mall / cube, SHOP_GC_START_EX, guild, party (+ CG_PARTY_SET_STATE),
  quests, dragon soul, refine, safebox, exchange.
- UI: in-game windows migrated 1:1 from the reference uiscript/root .py —
  char status (/stat), inventory+equipment, select-item ([SELECT_ITEM] quest
  token), system-option + game-option + ESC system menu, private-shop 39-grid,
  party info board, shop tabs, atlas, minimap, quickbar, chat, …
- EterGrnLib polish: GR2 material blend/two-sided, LOD crossfade, motion-event
  dispatch, contact shadow, ray-AABB picking, weapon grip pre-transform.

Portable asset IO (A1) — all extension/libgr2/formats/mtproto reads routed
through godot::FileAccess (res:// PCK works on iOS/Android); standalone-lib
*_path() kept for the non-Godot CTests. AssetResolver + PropertyRegistry
switched to a baked index (bake_asset_index.gd) instead of std::filesystem.

Mobile builds: build-{android,ios}.sh, export-android.sh, pack-assets.sh,
gen-debug-keystore.sh. Assets ship as a zip mounted at runtime by
project/asset_pack.gd (adb push now; HTTP download is a drop-in later).

ctest 10/10, 34 GDScript suites, macOS/iOS/Android all build.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013EJxkHiNKS4kybHS3XKyAJ
This commit is contained in:
shen
2026-08-31 20:02:12 +09:00
co-authored by Claude Sonnet 5
parent f4917a2b3b
commit 47baf6c0c6
414 changed files with 69568 additions and 385 deletions
+43
View File
@@ -0,0 +1,43 @@
# 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 + 事件
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: .msenvGroup/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 alphaRAW_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)
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_map_test tests/map_formats_test.cpp)
target_link_libraries(formats_map_test PRIVATE xrender::formats)
add_test(NAME formats.map_formats COMMAND formats_map_test)
# 真实 A1 资源路径(可选):设了就跑 live 断言。
if(DEFINED ENV{M2_ASSETS})
set_tests_properties(formats.map_formats PROPERTIES
ENVIRONMENT "M2_ASSETS=$ENV{M2_ASSETS}")
endif()
endif()
+132
View File
@@ -0,0 +1,132 @@
#include "area_data.h"
#include "m2_tokvec.h"
#include <cstdio>
#include <cstdlib>
namespace fmt {
namespace {
// "yaw#pitch#roll" 或单个数(= roll)。原客户端 Area.cpp 用 atoi,这里用 atof 更宽容。
void parse_ypr(const std::string& s, float& yaw, float& pitch, float& roll) {
yaw = pitch = roll = 0;
auto h1 = s.find('#');
if (h1 == std::string::npos) {
roll = (float)std::atof(s.c_str());
return;
}
auto h2 = s.find('#', h1 + 1);
yaw = (float)std::atof(s.substr(0, h1).c_str());
if (h2 == std::string::npos) {
pitch = (float)std::atof(s.substr(h1 + 1).c_str());
} else {
pitch = (float)std::atof(s.substr(h1 + 1, h2 - h1 - 1).c_str());
roll = (float)std::atof(s.substr(h2 + 1).c_str());
}
}
} // namespace
bool parse_area_data(const std::string& text, AreaData& out, std::string* err) {
TokVecMap tv;
std::string perr;
parse_tokvec(text, tv, &perr);
out = AreaData{};
if (!tv.has("areadatafile")) {
if (err) *err = "areadata 缺首行 `AreaDataFile`";
return false;
}
if (!tv.has("objectcount")) {
if (err) *err = "areadata 缺 ObjectCount";
return false;
}
out.declared_count = (int)tv.inum("objectcount");
for (int i = 0; i < out.declared_count; ++i) {
char key[32];
std::snprintf(key, sizeof(key), "object%03d", i);
const auto* v = tv.find(key);
if (!v) continue; // 原客户端也是 `continue`
if (v->size() < 4) {
if (err) *err = std::string("areadata 块 ") + key + " token 不足 4 个";
return false;
}
AreaObject o;
o.x = std::atof((*v)[0].c_str());
o.y = std::atof((*v)[1].c_str());
o.z = std::atof((*v)[2].c_str());
o.crc = (uint32_t)std::strtoul((*v)[3].c_str(), nullptr, 10);
if (v->size() > 4) parse_ypr((*v)[4], o.yaw, o.pitch, o.roll);
if (v->size() > 5) o.height_bias = (float)std::atof((*v)[5].c_str());
for (size_t k = 6; k < v->size(); ++k)
o.portal_ids.push_back(std::atoi((*v)[k].c_str()));
out.objects.push_back(std::move(o));
}
return true;
}
bool parse_area_ambience(const std::string& text, AreaAmbienceData& out, std::string* err) {
TokVecMap tv;
std::string perr;
parse_tokvec(text, tv, &perr);
out = AreaAmbienceData{};
if (!tv.has("areaambiencedatafile")) {
if (err) *err = "areaambiencedata 缺首行";
return false;
}
if (!tv.has("objectcount")) {
if (err) *err = "areaambiencedata 缺 ObjectCount";
return false;
}
out.declared_count = (int)tv.inum("objectcount");
for (int i = 0; i < out.declared_count; ++i) {
char key[32];
std::snprintf(key, sizeof(key), "object%03d", i);
const auto* v = tv.find(key);
if (!v || v->size() < 5) continue;
AreaAmbienceObject o;
o.x = std::atof((*v)[0].c_str());
o.y = std::atof((*v)[1].c_str());
o.z = std::atof((*v)[2].c_str());
o.crc = (uint32_t)std::strtoul((*v)[3].c_str(), nullptr, 10);
o.range = (float)std::atof((*v)[4].c_str());
out.objects.push_back(std::move(o));
}
return true;
}
bool parse_area_property(const std::string& text, AreaProperty& out, std::string* err) {
TokVecMap tv;
std::string perr;
parse_tokvec(text, tv, &perr);
out = AreaProperty{};
out.area_name = tv.str("areaname");
out.num_water = (int)tv.inum("numwater", 0);
(void)err;
return true;
}
// --- file wrappers ---
static bool load(const std::string& path, std::string& text, std::string* err) {
if (!read_file(path, text)) {
if (err) *err = "打不开 " + path;
return false;
}
return true;
}
bool parse_area_data_file(const std::string& p, AreaData& o, std::string* e) {
std::string t; return load(p, t, e) && parse_area_data(t, o, e);
}
bool parse_area_ambience_file(const std::string& p, AreaAmbienceData& o, std::string* e) {
std::string t; return load(p, t, e) && parse_area_ambience(t, o, e);
}
bool parse_area_property_file(const std::string& p, AreaProperty& o, std::string* e) {
std::string t; return load(p, t, e) && parse_area_property(t, o, e);
}
} // namespace fmt
+54
View File
@@ -0,0 +1,54 @@
// 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
+245
View File
@@ -0,0 +1,245 @@
#include "asset_resolver.h"
#include "m2_tokvec.h" // fmt::read_filehost 可注入 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 {
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) {
std::string text;
if (read_file(root + "/asset_index.txt", text) && text.rfind("MTIDX1", 0) == 0) {
return load_index(root, text, err);
}
return build(root, prio, err);
}
bool AssetResolver::exists(const std::string& vp) const {
return !resolve(vp, nullptr).empty();
}
std::vector<std::string> AssetResolver::all_rel() const {
std::vector<std::string> out;
out.reserve(by_rel.size());
for (const auto& kv : by_rel) out.push_back(kv.second.real_path);
return out;
}
} // namespace fmt
+64
View File
@@ -0,0 +1,64 @@
// 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_rootresolve 拼回真实路径)
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_filehost 可注入 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
+204
View File
@@ -0,0 +1,204 @@
#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);
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.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* sb = root.child("SkyBox")) {
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");
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
+60
View File
@@ -0,0 +1,60 @@
// .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, 0, 0}};
bool bg_enable = false, ch_enable = false;
Rgba bg_diffuse{{1, 1, 1, 1}}, bg_ambient{{0, 0, 0, 1}};
Rgba ch_diffuse{{1, 1, 1, 1}}, ch_ambient{{0, 0, 0, 1}};
};
struct EnvMaterial {
Rgba diffuse{{1, 1, 1, 1}}, ambient{{1, 1, 1, 1}}, emissive{{0, 0, 0, 1}};
};
struct EnvFog {
// 两种写法:老版 `Enable`/`NearDistance`/`FarDistance`A1 实盘用 `foglevel`0=关,>0=开)。
bool enable = false; // Enable != 0 || fog_level > 0
int fog_level = 0; // foglevel
float near_distance = 0, far_distance = 0;
Rgba color{{1, 1, 1, 1}};
};
struct EnvSkyBox {
Vec3f scale{{1, 1, 1}};
int gradient_level_upper = 0, gradient_level_lower = 0;
Vec2f cloud_scale{{1, 1}}, cloud_texture_scale{{1, 1}}, cloud_speed{{0, 0}};
float cloud_height = 0;
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 = 0, main_flare_size = 0;
std::string main_flare_texture;
};
struct Environment {
std::string script_type; // "EnvrionmentData"
float script_version = 0;
EnvDirLight dir_light;
EnvMaterial material;
EnvFog fog;
EnvSkyBox sky;
EnvLensFlare lens_flare;
};
bool parse_environment(const std::string& text, Environment& out, std::string* err);
bool parse_environment_file(const std::string& path, Environment& out, std::string* err);
} // namespace fmt
+60
View File
@@ -0,0 +1,60 @@
#include "m2_coord.h"
#include <cmath>
#include <cstdio>
namespace fmt::m2coord {
std::string tile_dir(int tile_x, int tile_y) {
char buf[16];
std::snprintf(buf, sizeof(buf), "%06u", tile_id(tile_x, tile_y));
return buf;
}
namespace {
Mat3 mul(const Mat3& a, const Mat3& b) {
Mat3 r{};
for (int i = 0; i < 3; ++i)
for (int j = 0; j < 3; ++j)
for (int k = 0; k < 3; ++k)
r.m[i * 3 + j] += a.m[i * 3 + k] * b.m[k * 3 + j];
return r;
}
Mat3 rot_x(double a) {
double c = std::cos(a), s = std::sin(a);
return {{1, 0, 0, 0, c, -s, 0, s, c}};
}
Mat3 rot_y(double a) {
double c = std::cos(a), s = std::sin(a);
return {{c, 0, s, 0, 1, 0, -s, 0, c}};
}
Mat3 rot_z(double a) {
double c = std::cos(a), s = std::sin(a);
return {{c, -s, 0, s, c, 0, 0, 0, 1}};
}
} // namespace
Mat3 ypr_basis(double yaw_deg, double pitch_deg, double roll_deg) {
const double d2r = 3.14159265358979323846 / 180.0;
return mul(mul(rot_y(yaw_deg * d2r), rot_x(pitch_deg * d2r)), rot_z(roll_deg * d2r));
}
Mat3 object_basis_godot(double yaw_deg, double pitch_deg, double roll_deg) {
// Conjugate the Metin2-space rotation through the Z-up -> Y-up axis swap
// C = rotate(-90deg, X): (x,y,z) -> (x,z,-y). R_godot = C * R_m2 * C^-1
const Mat3 C{{1, 0, 0, 0, 0, 1, 0, -1, 0}};
const Mat3 Ci{{1, 0, 0, 0, 0, -1, 0, 1, 0}};
return mul(mul(C, ypr_basis(yaw_deg, pitch_deg, roll_deg)), Ci);
}
bool parse_tile_dir(const std::string& name, int& tile_x, int& tile_y) {
if (name.size() != 6) return false;
for (char c : name)
if (c < '0' || c > '9') return false;
long id = std::strtol(name.c_str(), nullptr, 10);
tile_x = int(id / 1000);
tile_y = int(id % 1000);
return true;
}
} // namespace fmt::m2coord
+66
View File
@@ -0,0 +1,66 @@
// 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-upyaw 绕 Y、pitch 绕 X、**roll 绕 Z = 竖直轴 = 朝向**。
// areadata 单值旋转 → roll`Area.cpp:820`)。这一版**未**做 Z-up→Y-up 轴转换,
// 直接塞进 Godot Basis 会把「朝向」错当成绕 Godot Z 的翻滚 —— 放置物体请用 object_basis_godot()。
Mat3 ypr_basis(double yaw_deg, double pitch_deg, double roll_deg);
// 同上,但已把旋转共轭到 **Godot Y-up 空间**R_godot = C · R_m2 · C⁻¹,C = rotate(-90°,X))。
// 可直接作为 Godot Basis 使用:物体的 Godot 变换 = Transform3D(object_basis_godot(...), g) * make_conv()。
// roll(朝向)→ 绕 Godot +Y 的 heading,不再翻滚。行主序 3×3。
Mat3 object_basis_godot(double yaw_deg, double pitch_deg, double roll_deg);
} // namespace fmt::m2coord
+155
View File
@@ -0,0 +1,155 @@
#include "m2_tokvec.h"
#include <algorithm>
#include <cctype>
#include <cstdlib>
#include <fstream>
#include <sstream>
namespace fmt {
namespace {
std::string lower(std::string s) {
std::transform(s.begin(), s.end(), s.begin(),
[](unsigned char c) { return (char)std::tolower(c); });
return s;
}
bool ieq(const std::string& a, const char* b) {
return lower(a) == b;
}
// 一行 -> token 列表。`"..."` 单 token`#` / `//` 到行尾注释。
std::vector<std::string> tokenize_line(const std::string& line) {
std::vector<std::string> out;
size_t i = 0, n = line.size();
while (i < n) {
char c = line[i];
if (c == '#' || (c == '/' && i + 1 < n && line[i + 1] == '/')) break;
if (std::isspace((unsigned char)c)) { ++i; continue; }
if (c == '"') {
++i;
std::string s;
while (i < n && line[i] != '"') s += line[i++];
if (i < n) ++i; // 收尾引号
out.push_back(s);
continue;
}
std::string s;
while (i < n && !std::isspace((unsigned char)line[i]))
s += line[i++];
out.push_back(s);
}
return out;
}
} // namespace
bool TokVecMap::has(const std::string& key) const { return m.count(key) != 0; }
const std::vector<std::string>* TokVecMap::find(const std::string& key) const {
auto it = m.find(lower(key));
return it == m.end() ? nullptr : &it->second;
}
std::string TokVecMap::str(const std::string& key, const std::string& def) const {
auto* v = find(key);
return (v && !v->empty()) ? (*v)[0] : def;
}
long TokVecMap::inum(const std::string& key, long def) const {
auto* v = find(key);
return (v && !v->empty()) ? std::strtol((*v)[0].c_str(), nullptr, 10) : def;
}
double TokVecMap::num(const std::string& key, double def) const {
auto* v = find(key);
return (v && !v->empty()) ? std::strtod((*v)[0].c_str(), nullptr) : def;
}
bool parse_tokvec(const std::string& text, TokVecMap& out, std::string* err) {
out = TokVecMap{};
std::istringstream in(text);
std::string line;
std::string block_key; // 非空 = 正在收块
std::vector<std::string> block_tokens;
std::vector<std::string> seen_dups;
auto commit = [&](const std::string& key, std::vector<std::string> toks) {
std::string k = lower(key);
if (out.m.count(k)) {
out.dup_count++;
if (std::find(seen_dups.begin(), seen_dups.end(), k) == seen_dups.end()) {
seen_dups.push_back(k);
out.dup_keys.push_back(k);
}
return; // 保留第一个
}
out.m.emplace(std::move(k), std::move(toks));
};
while (std::getline(in, line)) {
if (!line.empty() && line.back() == '\r') line.pop_back();
auto toks = tokenize_line(line);
if (toks.empty()) continue;
if (!block_key.empty()) {
if (ieq(toks[0], "end")) {
commit(block_key, std::move(block_tokens));
block_key.clear();
block_tokens.clear();
} else {
for (auto& t : toks) block_tokens.push_back(std::move(t));
}
continue;
}
if (ieq(toks[0], "start")) {
if (toks.size() < 2) {
if (err) *err = "Start 缺块名";
return false;
}
block_key = toks[1];
block_tokens.clear();
continue;
}
// 顶层 `key v1 v2 ...`
std::string key = toks[0];
std::vector<std::string> vals(toks.begin() + 1, toks.end());
commit(key, std::move(vals));
}
if (!block_key.empty()) {
// 文件在块中截断 —— 宽容收尾但记 err
commit(block_key, std::move(block_tokens));
if (err) *err = "文件在 Start '" + block_key + "' 块中截断(缺 End";
}
return true;
}
static FileReader g_file_reader = nullptr;
void set_file_reader(FileReader r) { g_file_reader = r; }
bool read_file(const std::string& path, std::string& out) {
if (g_file_reader) return g_file_reader(path, out);
std::ifstream f(path, std::ios::binary);
if (!f) return false;
std::ostringstream ss;
ss << f.rdbuf();
out = ss.str();
return true;
}
bool parse_tokvec_file(const std::string& path, TokVecMap& out, std::string* err) {
std::string text;
if (!read_file(path, text)) {
if (err) *err = "打不开文件: " + path;
return false;
}
return parse_tokvec(text, out, err);
}
} // namespace fmt
+43
View File
@@ -0,0 +1,43 @@
// Metin2 地图文本格式的通用解析 —— 对标原客户端 EterBase `LoadMultipleTextData` +
// `CTokenVectorMap``GameLib/Area.cpp`、`PRTerrainLib/TextureSet.cpp` 都用它)。
//
// 语义:
// - 以空白分隔 token`"..."` 为单 token`#` 或 `//` 到行尾是注释。
// - 顶层行 `key v1 v2 ...` -> map[lower(key)] = [v1, v2, ...]
// - `Start <Name>` ... `End [<Name>]` -> map[lower(Name)] = [块内所有 token 展平]
// - 重复 key:保留第一个,`dup_count` 记录冲突数(W0 报告用)。
//
// 无 godot 依赖,纯 C++20,走 CTest。SHINSOO §9-W0 / §5。
#pragma once
#include <map>
#include <string>
#include <vector>
namespace fmt {
struct TokVecMap {
std::map<std::string, std::vector<std::string>> m;
int dup_count = 0; // 重复 key 次数
std::vector<std::string> dup_keys; // 重复的 key 名(去重后)
bool has(const std::string& key) const; // key 已小写
const std::vector<std::string>* find(const std::string& key) const;
// 便捷取值(key 传入时大小写不敏感)
std::string str(const std::string& key, const std::string& def = "") const;
long inum(const std::string& key, long def = 0) const;
double num(const std::string& key, double def = 0) const;
};
// 解析整段文本。语法层面不会失败(宽容解析);err 仅在明显结构错误时写。
bool parse_tokvec(const std::string& text, TokVecMap& out, std::string* err);
bool parse_tokvec_file(const std::string& path, TokVecMap& out, std::string* err);
// 小工具:整文件读入字符串。失败返回 false。
// Whole-file read used by every fmt loader. By default std::ifstream; the host
// app can install a reader that pulls from res:// (Godot PCK on iOS/Android) via
// set_file_reader(). The standalone CTests leave it unset -> plain ifstream.
using FileReader = bool (*)(const std::string& path, std::string& out);
void set_file_reader(FileReader r); // nullptr resets to the ifstream default
bool read_file(const std::string& path, std::string& out);
} // namespace fmt
+60
View File
@@ -0,0 +1,60 @@
#include "map_setting.h"
#include "m2_tokvec.h"
#include <cstdlib>
namespace fmt {
bool parse_map_setting(const std::string& text, MapSetting& out, std::string* err) {
TokVecMap tv;
std::string perr;
parse_tokvec(text, tv, &perr);
out = MapSetting{};
if (!tv.has("mapsize")) {
if (err) *err = "setting.txt 缺 MapSize";
return false;
}
if (const auto* v = tv.find("mapsize"); v && v->size() >= 2) {
out.map_size_x = (int)std::strtol((*v)[0].c_str(), nullptr, 10);
out.map_size_y = (int)std::strtol((*v)[1].c_str(), nullptr, 10);
} else {
if (err) *err = "setting.txt MapSize 需要两个整数";
return false;
}
if (out.map_size_x <= 0 || out.map_size_y <= 0) {
if (err) *err = "setting.txt MapSize 非法";
return false;
}
if (tv.has("cellscale")) out.cell_scale = (int)tv.inum("cellscale");
if (tv.has("heightscale")) out.height_scale = tv.num("heightscale");
out.view_radius = (int)tv.inum("viewradius", 0);
if (const auto* v = tv.find("baseposition"); v && v->size() >= 2) {
out.base_position_x = std::strtol((*v)[0].c_str(), nullptr, 10);
out.base_position_y = std::strtol((*v)[1].c_str(), nullptr, 10);
}
out.texture_set = tv.str("textureset");
out.environment = tv.str("environment");
if (out.cell_scale <= 0) {
if (err) *err = "setting.txt CellScale 非法";
return false;
}
return true;
}
bool parse_map_setting_file(const std::string& path, MapSetting& out, std::string* err) {
std::string text;
if (!read_file(path, text)) {
if (err) *err = "打不开 " + path;
return false;
}
return parse_map_setting(text, out, err);
}
} // namespace fmt
+26
View File
@@ -0,0 +1,26 @@
// setting.txtScriptType MapSetting)—— 地图全局设置。
// 对标 GameLib/MapOutdoorLoad.cpp 的 setting 解析。SHINSOO §5.1。
#pragma once
#include <string>
namespace fmt {
struct MapSetting {
int cell_scale = 200; // CellScalecm/格),默认取 PRTerrainLib 常量
double height_scale = 0.5; // HeightScaleraw 高度乘数)
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
+74
View File
@@ -0,0 +1,74 @@
// 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");
}
}
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);
if (const Node* med = root.group("MotionEventData")) {
int count = med->inum("MotionEventDataCount", int(med->groups.size()));
(void)count;
for (const Node& ev : med->groups) {
MotionEvent e;
e.type = ev.inum("MotionEventType");
e.start_time = ev.num("StartingTime");
e.effect_file = ev.str("EffectFileName");
e.sound_file = ev.str("SoundFileName");
vec3(ev.find("EffectPosition"), e.position);
out.events.push_back(std::move(e));
}
}
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);
if (const Node* med = root.group("MotionEventData"))
for (const Node& ev : med->groups) {
MotionEvent e;
e.type = ev.inum("MotionEventType");
e.start_time = ev.num("StartingTime");
e.effect_file = ev.str("EffectFileName");
e.sound_file = ev.str("SoundFileName");
vec3(ev.find("EffectPosition"), e.position);
out.events.push_back(std::move(e));
}
if (out.motion_gr2.empty()) { if (err) *err = "msa: 缺 MotionFileName (" + path + ")"; return false; }
return true;
}
} // namespace fmt
+31
View File
@@ -0,0 +1,31 @@
// .msa —— Metin2 动作描述(文本,ScriptType MotionData)。M2 T1。
#pragma once
#include <string>
#include <vector>
namespace fmt {
struct MotionEvent {
int type = 0; // MotionEventType10=effect, 4/5=sound, ...
float start_time = 0; // StartingTime
std::string effect_file; // EffectFileNametype 10
std::string sound_file; // SoundFileName
float position[3] = {0, 0, 0};
};
struct Msa {
std::string motion_gr2; // MotionFileName(原样,含 d:\... 前缀)
float duration = 0; // MotionDuration
float accumulation[3] = {0,0,0};// Accumulationroot 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;
};
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
+36
View File
@@ -0,0 +1,36 @@
// 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");
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
+26
View File
@@ -0,0 +1,26 @@
// .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 hair_path; // HairData.PathName
std::vector<HairEntry> hairs;
// 武器 / 时装挂点 / 材质类型按需补(PoC 先到发型 + base)
};
bool parse_msm(const std::string& text, Msm& out, std::string* err);
bool parse_msm_file(const std::string& path, Msm& out, std::string* err);
} // namespace fmt
+168
View File
@@ -0,0 +1,168 @@
#include "property.h"
#include "m2_tokvec.h" // read_file
#include <algorithm>
#include <cctype>
#include <cstdlib>
#include <filesystem>
#include <sstream>
namespace fmt {
namespace fs = std::filesystem;
std::string Property::get(const std::string& key, const std::string& def) const {
auto it = kv.find(key);
return it == kv.end() ? def : it->second;
}
PropertyType property_type_from_string(const std::string& s) {
std::string l = s;
std::transform(l.begin(), l.end(), l.begin(), [](unsigned char c) { return (char)std::tolower(c); });
if (l == "tree") return PropertyType::Tree;
if (l == "building") return PropertyType::Building;
if (l == "effect") return PropertyType::Effect;
if (l == "ambience") return PropertyType::Ambience;
if (l == "dungeonblock") return PropertyType::DungeonBlock;
return PropertyType::Unknown;
}
bool parse_property(const std::string& text, Property& out, std::string* err) {
out = Property{};
std::istringstream in(text);
std::string line;
// 行1: YPRT
if (!std::getline(in, line)) { if (err) *err = "空文件"; return false; }
while (!line.empty() && (line.back() == '\r' || line.back() == ' ' || line.back() == '\t'))
line.pop_back();
{
std::string t;
for (char c : line) if (!std::isspace((unsigned char)c)) t += c;
if (t != "YPRT") { if (err) *err = "首行不是 YPRT: '" + t + "'"; return false; }
}
// 行2: crc
if (!std::getline(in, line)) { if (err) *err = "缺 CRC 行"; return false; }
out.crc = (uint32_t)std::strtoul(line.c_str(), nullptr, 10);
if (out.crc == 0) { if (err) *err = "CRC 行解析为 0: '" + line + "'"; return false; }
// 行3+: key\t"value"
while (std::getline(in, line)) {
if (!line.empty() && line.back() == '\r') line.pop_back();
// key = 第一段非空白
size_t i = 0, n = line.size();
while (i < n && std::isspace((unsigned char)line[i])) ++i;
if (i >= n) continue;
size_t ks = i;
while (i < n && !std::isspace((unsigned char)line[i])) ++i;
std::string key = line.substr(ks, i - ks);
std::transform(key.begin(), key.end(), key.begin(),
[](unsigned char c) { return (char)std::tolower(c); });
// value = 引号内,或剩余去空白
while (i < n && std::isspace((unsigned char)line[i])) ++i;
std::string val;
if (i < n && line[i] == '"') {
++i;
while (i < n && line[i] != '"') val += line[i++];
} else {
val = line.substr(i);
while (!val.empty() && (val.back() == '\r' || std::isspace((unsigned char)val.back())))
val.pop_back();
}
out.kv[key] = val;
}
out.name = out.get("propertyname");
out.type_str = out.get("propertytype");
out.type = property_type_from_string(out.type_str);
return true;
}
bool parse_property_file(const std::string& path, Property& out, std::string* err) {
std::string text;
if (!read_file(path, text)) { if (err) *err = "打不开 " + path; return false; }
if (!parse_property(text, out, err)) return false;
out.source_path = path;
return true;
}
const Property* PropertyRegistry::find(uint32_t crc) const {
auto it = by_crc.find(crc);
return it == by_crc.end() ? nullptr : &it->second;
}
bool PropertyRegistry::scan(const std::string& root, std::string* err) {
std::error_code ec;
if (!fs::exists(root, ec)) { if (err) *err = "目录不存在: " + root; return false; }
static const char* kExts[] = {".prb", ".prt", ".pre", ".prd", ".pra"};
for (auto it = fs::recursive_directory_iterator(
root, fs::directory_options::skip_permission_denied, ec);
it != fs::recursive_directory_iterator(); it.increment(ec)) {
if (ec) { ec.clear(); continue; }
if (!it->is_regular_file(ec)) continue;
std::string ext = it->path().extension().string();
std::transform(ext.begin(), ext.end(), ext.begin(),
[](unsigned char c) { return (char)std::tolower(c); });
if (std::find(std::begin(kExts), std::end(kExts), ext) == std::end(kExts)) continue;
++files_scanned;
Property p;
std::string e2;
if (!parse_property_file(it->path().string(), p, &e2)) {
++parse_failed;
failed_paths.push_back(it->path().string() + " : " + e2);
continue;
}
type_counts[p.type_str.empty() ? "<none>" : p.type_str]++;
auto [ins, ok] = by_crc.emplace(p.crc, p);
if (!ok) ++crc_collisions;
}
return true;
}
bool PropertyRegistry::scan_list(const std::string& root,
const std::vector<std::string>& rel_paths, std::string* err) {
static const char* kExts[] = {".prb", ".prt", ".pre", ".prd", ".pra"};
std::string r = root;
if (!r.empty() && r.back() == '/') r.pop_back();
for (const std::string& rel : rel_paths) {
// 只认顶层 Property/ 下的(对齐 scan(root + "/Property"),不吃 patch 覆盖)
if (rel.size() < 9) continue;
{
std::string top = rel.substr(0, 8);
std::transform(top.begin(), top.end(), top.begin(),
[](unsigned char c) { return (char)std::tolower(c); });
if (top != "property" || (rel[8] != '/' && rel[8] != '\\')) continue;
}
std::string ext;
size_t dot = rel.find_last_of('.');
if (dot != std::string::npos) {
ext = rel.substr(dot);
std::transform(ext.begin(), ext.end(), ext.begin(),
[](unsigned char c) { return (char)std::tolower(c); });
}
if (std::find(std::begin(kExts), std::end(kExts), ext) == std::end(kExts)) continue;
++files_scanned;
Property p;
std::string e2;
std::string full = r + "/" + rel;
if (!parse_property_file(full, p, &e2)) { // read_file -> host FileAccess -> res:// ok
++parse_failed;
failed_paths.push_back(full + " : " + e2);
continue;
}
type_counts[p.type_str.empty() ? "<none>" : p.type_str]++;
auto [ins, ok] = by_crc.emplace(p.crc, p);
if (!ok) ++crc_collisions;
}
if (files_scanned == 0) {
if (err) *err = "清单里没有属性文件(.pr*";
return false;
}
return true;
}
} // namespace fmt
+55
View File
@@ -0,0 +1,55 @@
// 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/*.prastd::filesystem)。
bool scan(const std::string& root, std::string* err);
// 同上,但文件清单外部给(PCK 里没法 recursive_directory_iterator)。
// rel_paths 里挑属性扩展名的,逐个 read_file(root + "/" + rel) 解析。
bool scan_list(const std::string& root, const std::vector<std::string>& rel_paths,
std::string* err);
};
} // namespace fmt
+61
View File
@@ -0,0 +1,61 @@
#include "splat.h"
namespace fmt {
void build_splat(const TileMap& tile, int runtime_layer_count, SplatSet& out) {
out = SplatSet{};
const int S = SPLAT_RAW_XY; // 258
const auto& t = tile.raw; // 258*258
if ((int)t.size() != S * S) return;
// 直方图 + 出现过的图层
int maxidx = 0;
for (uint8_t v : t) maxidx = std::max(maxidx, (int)v);
out.tile_histogram.assign(maxidx + 1, 0);
for (uint8_t v : t) out.tile_histogram[v]++;
const int limit = std::max(runtime_layer_count - 1, maxidx);
for (int i = 1; i <= limit; ++i) {
if (i >= (int)out.tile_histogram.size() || out.tile_histogram[i] == 0) continue;
SplatLayer L;
L.layer = i;
L.alpha.resize(size_t(S) * S);
int cov = 0;
for (int y = 0; y < S; ++y) {
const uint8_t* row = &t[size_t(y) * S];
const uint8_t* up = y > 0 ? row - S : nullptr;
const uint8_t* dn = y < S - 1 ? row + S : nullptr;
uint8_t* a = &L.alpha[size_t(y) * S];
for (int x = 0; x < S; ++x) {
uint8_t tn = row[x];
if (tn == i) {
a[x] = 0xFF;
++cov;
} else if (tn > i) {
bool found = false;
if (x > 0 && row[x - 1] == i) found = true;
else if (x < S - 1 && row[x + 1] == i) found = true;
else if (up) {
if (up[x] == i) found = true;
else if (x > 0 && up[x - 1] == i) found = true;
else if (x < S - 1 && up[x + 1] == i) found = true;
}
if (!found && dn) {
if (dn[x] == i) found = true;
else if (x > 0 && dn[x - 1] == i) found = true;
else if (x < S - 1 && dn[x + 1] == i) found = true;
}
a[x] = found ? 0xFF : 0x00;
if (found) ++cov;
} else {
a[x] = 0x00;
}
}
}
L.coverage = cov;
out.layers.push_back(std::move(L));
}
}
} // namespace fmt
+30
View File
@@ -0,0 +1,30 @@
// W2 —— tile.raw -> 每图层 258×258 alphasplat 权重)。
// 逐字节照 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
+100
View File
@@ -0,0 +1,100 @@
#include "spt.h"
#include "m2_tokvec.h" // read_file
#include <cctype>
#include <cstdint>
#include <cstring>
namespace fmt {
namespace {
bool ends_with_ci(const std::string& s, const char* suf) {
size_t n = std::strlen(suf);
if (s.size() < n) return false;
for (size_t i = 0; i < n; ++i)
if (std::tolower((unsigned char)s[s.size() - n + i]) != std::tolower((unsigned char)suf[i]))
return false;
return true;
}
std::string token_ci(const std::string& bytes, const char* needle) {
const size_t n = std::strlen(needle);
if (n == 0 || bytes.size() < n) return "";
for (size_t i = 0; i + n <= bytes.size(); ++i) {
bool match = true;
for (size_t j = 0; j < n; ++j) {
if (std::tolower((unsigned char)bytes[i + j]) !=
std::tolower((unsigned char)needle[j])) {
match = false;
break;
}
}
if (!match) continue;
size_t e = i + n;
while (e < bytes.size()) {
const unsigned char c = (unsigned char)bytes[e];
if (!(std::isalnum(c) || c == '_' || c == '-')) break;
++e;
}
return bytes.substr(i, e - i);
}
return "";
}
std::string as_dds(std::string s) {
if (s.empty()) return s;
const size_t dot = s.find_last_of('.');
if (dot != std::string::npos) s.resize(dot);
return s + ".dds";
}
} // namespace
bool sniff_spt(const std::string& b, SptInfo& out) {
out = SptInfo{};
if (b.size() < 20) return false;
std::memcpy(&out.header_dword, b.data(), 4);
uint32_t maglen = 0;
std::memcpy(&maglen, b.data() + 4, 4);
if (maglen >= 4 && maglen <= 32 && 8 + maglen <= b.size()) {
out.magic.assign(b.data() + 8, b.data() + 8 + maglen);
// 去掉可能的尾随 NUL
while (!out.magic.empty() && out.magic.back() == '\0') out.magic.pop_back();
}
out.ok = out.magic.rfind("__IdvSpt", 0) == 0;
// 扫描可打印 ASCII 段,收 .tga / .dds 结尾的
std::string cur;
auto flush = [&] {
if (cur.size() >= 5 && (ends_with_ci(cur, ".tga") || ends_with_ci(cur, ".dds"))) {
// 只留文件名基础部分之后的路径(原样)
out.texture_refs.push_back(cur);
}
cur.clear();
};
for (unsigned char c : b) {
if (c >= 0x20 && c < 0x7F) {
cur += (char)c;
} else {
flush();
}
}
flush();
// 这两个名字在真实 v2 .spt 中常紧跟二进制标记,不会由上面的
// "字符串必须正好以扩展名结尾" 规则捕获。
out.composite_texture = as_dds(token_ci(b, "CompositeMap"));
out.self_shadow_texture = as_dds(token_ci(b, "CompositeShadowMap"));
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
+31
View File
@@ -0,0 +1,31 @@
// .spt —— SpeedTree RT 私有格式(IDV Inc.)。
//
// W4 调研结论(2026-08-29):**几何无法读**。`.spt` 的解析在闭源 `CSpeedTreeRT::LoadTree()`
// 里(`m2dev-client-src-main/extern/library/SpeedTree/speedtree_static.lib`,实测 COFF x86-64
// IDV 专有,不能链进 macOS/移动端、不可随意分发);`SpeedTreeLib/` 是渲染 wrapper,不含 parser。
// → 运行时使用读取真实 bark/composite atlas 的 proxy;真几何走经授权的 Windows x64 离线 exporter。
//
// 这里只做「嗅探」:读文件头 magic + version,抽取内嵌贴图和 composite atlas token。
// 不解析枝干/叶片几何。无 godot 依赖。
#pragma once
#include <string>
#include <vector>
namespace fmt {
struct SptInfo {
bool ok = false;
std::string magic; // "__IdvSpt_02_" 等
uint32_t header_dword = 0; // magic 前的第一个 u32(版本/大小,未定)
std::vector<std::string> texture_refs; // 内嵌的 *.tga / *.dds 路径(原样)
// SpeedTree 2 把这两个 atlas 名写成不一定以 NUL 结尾的 token(例如
// "CompositeMapB1#N"),普通 printable-string 扫描会漏掉。统一转成 .dds,
// 供无几何 reader 的 proxy 渲染使用。
std::string composite_texture;
std::string self_shadow_texture;
};
bool sniff_spt(const std::string& bytes, SptInfo& out);
bool sniff_spt_file(const std::string& path, SptInfo& out);
} // namespace fmt
+106
View File
@@ -0,0 +1,106 @@
#include "terrain_files.h"
#include "m2_tokvec.h" // read_file
#include <cstring>
namespace fmt {
namespace {
uint16_t rd_u16(const uint8_t* p) { return uint16_t(p[0]) | (uint16_t(p[1]) << 8); }
} // namespace
bool load_height_map(const std::string& path, HeightMap& out, std::string* err) {
std::string buf;
if (!read_file(path, buf)) { if (err) *err = "打不开 " + path; return false; }
const size_t need = size_t(HEIGHTMAP_RAW_XY) * HEIGHTMAP_RAW_XY * 2;
if (buf.size() != need) {
if (err) *err = "height.raw 大小 " + std::to_string(buf.size()) +
" != " + std::to_string(need) + "131*131*2";
return false;
}
out.raw.resize(size_t(HEIGHTMAP_RAW_XY) * HEIGHTMAP_RAW_XY);
const auto* p = reinterpret_cast<const uint8_t*>(buf.data());
for (size_t i = 0; i < out.raw.size(); ++i) out.raw[i] = rd_u16(p + i * 2);
return true;
}
bool load_tile_map(const std::string& path, TileMap& out, std::string* err) {
std::string buf;
if (!read_file(path, buf)) { if (err) *err = "打不开 " + path; return false; }
const size_t need = size_t(TILEMAP_RAW_XY) * TILEMAP_RAW_XY;
if (buf.size() != need) {
if (err) *err = "tile.raw 大小 " + std::to_string(buf.size()) +
" != " + std::to_string(need) + "258*258";
return false;
}
out.raw.assign(buf.begin(), buf.end());
return true;
}
bool load_attr_map(const std::string& path, AttrMap& out, std::string* err) {
std::string buf;
if (!read_file(path, buf)) { if (err) *err = "打不开 " + path; return false; }
const size_t payload = size_t(ATTRMAP_XY) * ATTRMAP_XY;
if (buf.size() != 6 + payload) {
if (err) *err = "attr.atr 大小 " + std::to_string(buf.size()) +
" != " + std::to_string(6 + payload) + "6B 头 + 256*256";
return false;
}
const auto* p = reinterpret_cast<const uint8_t*>(buf.data());
uint16_t magic = rd_u16(p), w = rd_u16(p + 2), h = rd_u16(p + 4);
if (magic != ATTR_MAGIC) {
if (err) *err = "attr.atr magic " + std::to_string(magic) + " != 2634";
return false;
}
if (w != ATTRMAP_XY || h != ATTRMAP_XY) {
if (err) *err = "attr.atr 尺寸头 " + std::to_string(w) + "x" + std::to_string(h) +
" != 256x256";
return false;
}
out.data.assign(buf.begin() + 6, buf.end());
return true;
}
bool load_water_map(const std::string& path, WaterMap& out, std::string* err) {
std::string buf;
if (!read_file(path, buf)) { if (err) *err = "打不开 " + path; return false; }
if (buf.size() < 7) { if (err) *err = "water.wtr 太小"; return false; }
const auto* p = reinterpret_cast<const uint8_t*>(buf.data());
uint16_t magic = rd_u16(p), w = rd_u16(p + 2), h = rd_u16(p + 4);
uint8_t layers = p[6];
if (magic != WATER_MAGIC) {
if (err) *err = "water.wtr magic " + std::to_string(magic) + " != 5426";
return false;
}
if (w != WATERMAP_XY || h != WATERMAP_XY) {
if (err) *err = "water.wtr 尺寸头 != 128x128";
return false;
}
const size_t ids = size_t(WATERMAP_XY) * WATERMAP_XY;
const size_t rest = buf.size() - 7;
// Terrain.cpp 接受两种:u16 层高 或 long(u32) 层高。
size_t hsize = 0;
if (rest == ids + size_t(layers) * 2) hsize = 2;
else if (rest == ids + size_t(layers) * 4) hsize = 4;
else if (rest == ids && layers == 0) hsize = 0;
else {
if (err) *err = "water.wtr 数据段大小 " + std::to_string(rest) +
" 与 layers=" + std::to_string(layers) + " 不符";
return false;
}
out.layer_count = layers;
out.ids.assign(buf.begin() + 7, buf.begin() + 7 + ids);
out.heights.clear();
const auto* hp = p + 7 + ids;
for (int i = 0; i < layers; ++i) {
if (hsize == 2) out.heights.push_back((int16_t)rd_u16(hp + i * 2));
else if (hsize == 4)
out.heights.push_back((int32_t)(uint32_t(hp[i * 4]) | (uint32_t(hp[i * 4 + 1]) << 8) |
(uint32_t(hp[i * 4 + 2]) << 16) |
(uint32_t(hp[i * 4 + 3]) << 24)));
}
return true;
}
} // namespace fmt
+44
View File
@@ -0,0 +1,44 @@
// 区块二进制:height.raw / tile.raw / attr.atr / water.wtr。
// 真值来源:PRTerrainLib/Terrain.cppLoadHeightMap / 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*1280xFF = 无水
std::vector<int32_t> heights; // layer_count 个
};
bool load_height_map(const std::string& path, HeightMap& out, std::string* err);
bool load_tile_map(const std::string& path, TileMap& out, std::string* err);
bool load_attr_map(const std::string& path, AttrMap& out, std::string* err);
bool load_water_map(const std::string& path, WaterMap& out, std::string* err);
} // namespace fmt
+102
View File
@@ -0,0 +1,102 @@
#include "terrain_mesh.h"
#include "m2_coord.h"
#include <algorithm>
#include <cmath>
namespace fmt {
namespace {
constexpr int CS = m2coord::CELLSCALE; // 200 cm
constexpr double CS_M = double(CS) * m2coord::CM_TO_M; // 2.0 m
} // namespace
double terrain_height_at(const HeightMap& hm, double lx_cm, double ly_cm, double hs) {
// 照 CTerrain::GetHeight(本地坐标版,调用方已把全局->本地 + y 取绝对值)
if (lx_cm < 0 || ly_cm < 0 || lx_cm > double(m2coord::TERRAIN_SIZE) * CS ||
ly_cm > double(m2coord::TERRAIN_SIZE) * CS)
return 0.0;
long xi = (long)lx_cm, yi = (long)ly_cm;
long xdist = xi % CS, ydist = yi % CS;
long x = xi / CS, y = yi / CS;
const double oo = 1.0 / double(CS);
double h1 = hm.at((int)x, (int)y) * hs; // TL
double h2 = hm.at((int)x + 1, (int)y + 1) * hs; // BR
if (xdist <= ydist) { // 左三角:TL, BR, BL
double h3 = hm.at((int)x, (int)y + 1) * hs;
double xslope = (h2 - h3) * oo;
double yslope = (h3 - h1) * oo;
return h1 + (xdist * xslope + ydist * yslope);
}
// 右三角:TL, BR, TR
double h3 = hm.at((int)x + 1, (int)y) * hs;
double xslope = (h3 - h1) * oo;
double yslope = (h2 - h3) * oo;
return h1 + (xdist * xslope + ydist * yslope);
}
void build_terrain_mesh(const HeightMap& hm, int tile_x, int tile_y, double hs,
TerrainMesh& out, bool ccw_from_above) {
out = TerrainMesh{};
const int N = TerrainMesh::VERTS_XY; // 129
out.positions.resize(size_t(N) * N * 3);
out.normals.resize(size_t(N) * N * 3);
out.uvs.resize(size_t(N) * N * 2);
out.indices.reserve(size_t(TerrainMesh::QUADS_XY) * TerrainMesh::QUADS_XY * 6);
// Godot 空间原点:见 area_data.h 说明 —— 区块 y 取负,position_to_godot 再翻正。
const double X0 = double(tile_x) * m2coord::CHUNK_CM * m2coord::CM_TO_M;
const double Z0 = double(tile_y) * m2coord::CHUNK_CM * m2coord::CM_TO_M;
auto height_m = [&](int i, int j) -> double {
return hm.at(i, j) * hs * m2coord::CM_TO_M;
};
double miny = 1e30, maxy = -1e30;
for (int j = 0; j < N; ++j) {
for (int i = 0; i < N; ++i) {
const double gy = height_m(i, j);
const size_t o = (size_t(j) * N + i) * 3;
out.positions[o + 0] = float(X0 + i * CS_M);
out.positions[o + 1] = float(gy);
out.positions[o + 2] = float(Z0 + j * CS_M);
const size_t uo = (size_t(j) * N + i) * 2;
out.uvs[uo + 0] = float(i) / float(TerrainMesh::QUADS_XY);
out.uvs[uo + 1] = float(j) / float(TerrainMesh::QUADS_XY);
miny = std::min(miny, gy);
maxy = std::max(maxy, gy);
// 中心差分(用边界样本,跨区块无缝);y=f(x,z) 的法线 = (-df/dx, 1, -df/dz)
const double dh_di = (height_m(i + 1, j) - height_m(i - 1, j)) / (2.0 * CS_M);
const double dh_dj = (height_m(i, j + 1) - height_m(i, j - 1)) / (2.0 * CS_M);
double nx = -dh_di, ny = 1.0, nz = -dh_dj;
const double len = std::sqrt(nx * nx + ny * ny + nz * nz);
out.normals[o + 0] = float(nx / len);
out.normals[o + 1] = float(ny / len);
out.normals[o + 2] = float(nz / len);
}
}
out.min_y = float(miny);
out.max_y = float(maxy);
auto vid = [N](int i, int j) { return int32_t(j * N + i); };
for (int j = 0; j < TerrainMesh::QUADS_XY; ++j) {
for (int i = 0; i < TerrainMesh::QUADS_XY; ++i) {
int32_t TL = vid(i, j), TR = vid(i + 1, j);
int32_t BL = vid(i, j + 1), BR = vid(i + 1, j + 1);
// 对角线 TL-BR= GetHeight 的 h1/h2
if (ccw_from_above) {
out.indices.insert(out.indices.end(), {TL, BR, BL});
out.indices.insert(out.indices.end(), {TL, TR, BR});
} else {
out.indices.insert(out.indices.end(), {TL, BL, BR});
out.indices.insert(out.indices.end(), {TL, BR, TR});
}
}
}
}
} // namespace fmt
+38
View File
@@ -0,0 +1,38 @@
// W1 —— 一个区块的地形几何(heightmap -> 顶点 / 法线 / 索引),纯数学、无 godot 依赖。
// 三角划分与法线照 GameLib/AreaTerrain.cpp CTerrain::GetHeight / CalculateNormal。
// SHINSOO §9-W1。
//
// 129×129 可见顶点,128×128 quad。每个 quad 对角线 = TL->BRGetHeight 的 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/vertGodot 空间(米,Y-up),含区块原点
std::vector<float> normals; // 3/vertGodot 空间,单位向量
std::vector<float> uvs; // 2/vert,区块内 [0,1](用于 splat / 地表贴图)
std::vector<int32_t> indices; // 3/tri6/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
+512
View File
@@ -0,0 +1,512 @@
// 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
Color 0.69 0.74 0.83 1.0
}
Group SkyBox
{
Scale 3500.0 3500.0 3500.0
CloudTextureFileName "d:/ymir work/environment/clouds_zone01.tga"
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(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 /");
}
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");
}
// EnvironmentA1.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");
// 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;
}
+34
View File
@@ -0,0 +1,34 @@
#include <msa.h>
#include <cmath>
#include <cstdio>
#include <string>
int main() {
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;
}
return 0;
}
+82
View File
@@ -0,0 +1,82 @@
// .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"
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.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;
}
+126
View File
@@ -0,0 +1,126 @@
// 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; }
if (ieq(tok, "Group")) {
if (i + 2 >= t.size()) { if (err) *err = "Group 缺名字 / {"; return false; }
Node sub;
sub.name = t[i + 1];
if (t[i + 2] != "{") { if (err) *err = "Group '" + sub.name + "' 后缺 {"; return false; }
i += 3;
if (!parse_body(t, i, sub, err)) return false;
node.groups.push_back(std::move(sub));
continue;
}
// 普通行:从 tok 起,直到下一个会开启新语义的 token(Group / } / 或另一行的 key
// .msa/.msm 是「一行一 key」,我们靠换行还原不了(已丢),改用:
// key + 其后所有非 Group/非} token 直到遇到一个「像 key 的」…太脆。
// 实际这些文件每行 = key + 定长值,值都是数字 / 带引号串 / 简单标识。
// 简化:把连续的 token 收进当前行,遇到 Group/} 或「首字母大写且后面还跟 Group 结构」时断。
// 经验规则:值不会是 "Group"key 之后收 1..N 个值,遇到下一个满足「不是纯数字、不是带过引号」
// 的裸标识就当新行开始。对 dance_1.msa / warrior_w.msm 足够。
std::vector<std::string> line;
line.push_back(t[i++]);
while (i < t.size()) {
const std::string& x = t[i];
if (x == "}" || ieq(x, "Group")) break;
// 判断 x 是否是「新行的 key」:不含数字开头、不含 '.'、'/'、'\\'、'"' 迹象,
// 且当前行已至少有 1 个值
bool looks_key = line.size() >= 2 &&
(std::isalpha((unsigned char)x[0]) || x[0] == '_') &&
x.find_first_of("/\\.:") == std::string::npos;
// 数字 / 负号 / 小数一律当值
bool is_num = !x.empty() && (x[0] == '-' || x[0] == '+' || x[0] == '.' ||
std::isdigit((unsigned char)x[0]));
if (looks_key && !is_num) break;
line.push_back(x);
++i;
}
node.lines.push_back(std::move(line));
}
return true; // 流尾(根节点)
}
} // namespace
const std::vector<std::string>* Node::find(const char* key) const {
for (const auto& l : lines) if (!l.empty() && ieq(l[0], key)) return &l;
return nullptr;
}
std::string Node::str(const char* key, const std::string& def) const {
auto* l = find(key);
return (l && l->size() >= 2) ? (*l)[1] : def;
}
float Node::num(const char* key, float def) const {
auto* l = find(key);
return (l && l->size() >= 2) ? float(std::atof((*l)[1].c_str())) : def;
}
int Node::inum(const char* key, int def) const {
auto* l = find(key);
return (l && l->size() >= 2) ? std::atoi((*l)[1].c_str()) : def;
}
const Node* Node::group(const char* name) const {
for (const auto& g : groups) if (ieq(g.name, name)) return &g;
return nullptr;
}
bool parse_textscript(const std::string& text, Node& root, std::string* err) {
root = Node{};
auto toks = tokenize_all(text);
size_t i = 0;
return parse_body(toks, i, root, err);
}
bool parse_textscript_file(const std::string& path, Node& root, std::string* err) {
std::string s;
if (!read_file(path, s)) { if (err) *err = "cannot open " + path; return false; }
return parse_textscript(s, root, err);
}
} // namespace fmt
+27
View File
@@ -0,0 +1,27 @@
// Metin2 文本脚本(.msa / .msm / 各种 .txt)—— token 树解析。
// 语法:空白分隔 token`key value...` 行;`Group Name { ... }` 块;`"quoted"` 串;`#` 到行尾注释。
// 照 EterBase/CTextFileLoader + EterLib CTokenVector 的效果。M2 T1。
#pragma once
#include <string>
#include <vector>
namespace fmt {
struct Node {
std::string name; // Group 名(根节点为空)
std::vector<std::vector<std::string>> lines; // 非 Group 行:token 列表([0]=key
std::vector<Node> groups; // 子 Group
// 便捷取值(在本节点的 lines 里找 key,大小写不敏感)
const std::vector<std::string>* find(const char* key) const;
std::string str(const char* key, const std::string& def = "") const; // 取第 1 个值(去引号)
float num(const char* key, float def = 0) const;
int inum(const char* key, int def = 0) const;
const Node* group(const char* name) const;
};
// 解析整段文本。失败返回 false 并写 err。
bool parse_textscript(const std::string& text, Node& root, std::string* err);
bool parse_textscript_file(const std::string& path, Node& root, std::string* err);
} // namespace fmt
+66
View File
@@ -0,0 +1,66 @@
#include "texture_set.h"
#include "m2_tokvec.h"
#include <cstdio>
#include <cstdlib>
namespace fmt {
bool parse_texture_set(const std::string& text, TextureSet& out, std::string* err) {
TokVecMap tv;
std::string perr;
parse_tokvec(text, tv, &perr);
out = TextureSet{};
if (!tv.has("textureset")) {
if (err) *err = "textureset 缺首行 `TextureSet`";
return false;
}
if (!tv.has("texturecount")) {
if (err) *err = "textureset 缺 TextureCount";
return false;
}
out.declared_count = (int)tv.inum("texturecount");
if (out.declared_count < 0) {
if (err) *err = "textureset TextureCount 非法";
return false;
}
for (int i = 1; i <= out.declared_count; ++i) {
char key[32];
std::snprintf(key, sizeof(key), "texture%03d", i);
const auto* v = tv.find(key);
if (!v) {
if (err) *err = std::string("textureset 缺块 ") + key;
return false;
}
if (v->size() < 8) {
if (err) *err = std::string("textureset 块 ") + key + " token 不足 8 个";
return false;
}
TextureLayer L;
L.texture = (*v)[0];
L.u_scale = (float)std::atof((*v)[1].c_str());
L.v_scale = (float)std::atof((*v)[2].c_str());
L.u_offset = (float)std::atof((*v)[3].c_str());
L.v_offset = (float)std::atof((*v)[4].c_str());
L.splat_enabled = std::atoi((*v)[5].c_str()) != 0;
L.height_begin = (unsigned short)std::atoi((*v)[6].c_str());
L.height_end = (unsigned short)std::atoi((*v)[7].c_str());
out.layers.push_back(std::move(L));
}
return true;
}
bool parse_texture_set_file(const std::string& path, TextureSet& out, std::string* err) {
std::string text;
if (!read_file(path, text)) {
if (err) *err = "打不开 " + path;
return false;
}
return parse_texture_set(text, out, err);
}
} // namespace fmt
+31
View File
@@ -0,0 +1,31 @@
// 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