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:
@@ -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: .msenv(Group/List 文本树)
|
||||
property.cpp # W0: .prb/.prt/.pre/.prd/.pra + CRC 注册表(BACKLOG E13)
|
||||
asset_resolver.cpp # W0: 虚拟路径 -> 散文件(BACKLOG G7)
|
||||
terrain_mesh.cpp # W1: heightmap -> 顶点/法线/索引 + GetHeight 三角插值
|
||||
splat.cpp # W2: tile.raw -> 每图层 258x258 alpha(RAW_GenerateSplat 端口)
|
||||
spt.cpp # W4: .spt 嗅探(几何不可读,见文件头注释)
|
||||
)
|
||||
add_library(xrender::formats ALIAS xr_formats)
|
||||
target_include_directories(xr_formats PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
|
||||
target_link_libraries(xr_formats PUBLIC xrender::libgr2)
|
||||
target_compile_features(xr_formats PUBLIC cxx_std_20)
|
||||
|
||||
if(BUILD_TESTING)
|
||||
add_executable(formats_msa_test tests/msa_test.cpp)
|
||||
target_link_libraries(formats_msa_test PRIVATE xrender::formats)
|
||||
add_test(NAME formats.msa_loop_data COMMAND formats_msa_test)
|
||||
|
||||
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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,245 @@
|
||||
#include "asset_resolver.h"
|
||||
#include "m2_tokvec.h" // fmt::read_file(host 可注入 FileAccess)
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <filesystem>
|
||||
#include <sstream>
|
||||
|
||||
namespace fmt {
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
namespace {
|
||||
|
||||
std::string lower(std::string s) {
|
||||
std::transform(s.begin(), s.end(), s.begin(),
|
||||
[](unsigned char c) { return (char)std::tolower(c); });
|
||||
return s;
|
||||
}
|
||||
|
||||
// "a/b/./c/../d" -> "a/b/d"(不越过根;越根的 ".." 直接丢)
|
||||
std::string collapse(const std::string& in) {
|
||||
std::vector<std::string> parts;
|
||||
std::string cur;
|
||||
auto flush = [&] {
|
||||
if (cur.empty()) return;
|
||||
if (cur == ".") {
|
||||
} else if (cur == "..") {
|
||||
if (!parts.empty()) parts.pop_back();
|
||||
} else {
|
||||
parts.push_back(cur);
|
||||
}
|
||||
cur.clear();
|
||||
};
|
||||
for (char c : in) {
|
||||
if (c == '/') flush();
|
||||
else cur += c;
|
||||
}
|
||||
flush();
|
||||
std::string out;
|
||||
for (size_t i = 0; i < parts.size(); ++i) {
|
||||
if (i) out += '/';
|
||||
out += parts[i];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const char* kYmir = "ymir work/";
|
||||
|
||||
// 若 path 含 "ymir work/",返回从那里起的后缀,否则 ""。
|
||||
std::string ymir_suffix(const std::string& norm) {
|
||||
auto p = norm.find(kYmir);
|
||||
if (p == std::string::npos) return "";
|
||||
return norm.substr(p);
|
||||
}
|
||||
|
||||
// 一级目录归类:0=基础(在 priority 里),1=普通,2=patch/补丁
|
||||
int pack_rank(const std::string& top, const std::vector<std::string>& prio) {
|
||||
std::string t = lower(top);
|
||||
for (size_t i = 0; i < prio.size(); ++i)
|
||||
if (lower(prio[i]) == t) return (int)i; // 0..N-1
|
||||
if (t.rfind("metin2_patch_", 0) == 0 || t.rfind("patch", 0) == 0) return 100000;
|
||||
return 50000;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::vector<std::string> AssetResolver::default_priority() {
|
||||
return {"Zone", "Terrain", "ETC", "PC", "Tree", "Property", "Effect",
|
||||
"Monster", "NPC", "season2", "season3_eu"};
|
||||
}
|
||||
|
||||
std::string AssetResolver::normalize(const std::string& vp) {
|
||||
std::string s = vp;
|
||||
for (char& c : s)
|
||||
if (c == '\\') c = '/';
|
||||
s = lower(s);
|
||||
// 去盘符 "d:/..." / "d:..."
|
||||
if (s.size() >= 2 && std::isalpha((unsigned char)s[0]) && s[1] == ':')
|
||||
s = s.substr(2);
|
||||
while (!s.empty() && s[0] == '/') s.erase(s.begin());
|
||||
return collapse(s);
|
||||
}
|
||||
|
||||
bool AssetResolver::build(const std::string& root, const std::vector<std::string>& prio,
|
||||
std::string* err) {
|
||||
assets_root = root;
|
||||
std::error_code ec;
|
||||
if (!fs::exists(root, ec)) {
|
||||
if (err) *err = "assets 根不存在: " + root;
|
||||
return false;
|
||||
}
|
||||
|
||||
// 收集 (rank, real_path, rel_norm) 后按 rank 稳定排序 —— 保证先注册者 = 高优先级
|
||||
struct Rec {
|
||||
int rank;
|
||||
std::string real, rel;
|
||||
};
|
||||
std::vector<Rec> recs;
|
||||
fs::path base(root);
|
||||
for (auto it = fs::recursive_directory_iterator(
|
||||
root, fs::directory_options::skip_permission_denied, ec);
|
||||
it != fs::recursive_directory_iterator(); it.increment(ec)) {
|
||||
if (ec) { ec.clear(); continue; }
|
||||
if (!it->is_regular_file(ec)) continue;
|
||||
std::string rel_real = fs::relative(it->path(), base, ec).generic_string();
|
||||
std::string rel = lower(rel_real);
|
||||
if (rel.empty()) continue;
|
||||
std::string top = rel.substr(0, rel.find('/'));
|
||||
// real_path 存**相对** assets_root(保留原始大小写);resolve() 拼回。
|
||||
recs.push_back({pack_rank(top, prio), rel_real, rel});
|
||||
}
|
||||
std::stable_sort(recs.begin(), recs.end(),
|
||||
[](const Rec& a, const Rec& b) { return a.rank < b.rank; });
|
||||
|
||||
std::vector<std::string> seen_conflicts;
|
||||
for (auto& r : recs) {
|
||||
++files_indexed;
|
||||
// by_rel
|
||||
{
|
||||
auto& e = by_rel[r.rel];
|
||||
if (e.real_path.empty()) e.real_path = r.real;
|
||||
e.all_candidates.push_back(r.real);
|
||||
}
|
||||
// by_ymir
|
||||
std::string suf = ymir_suffix(r.rel);
|
||||
if (!suf.empty()) {
|
||||
auto& e = by_ymir[suf];
|
||||
if (e.real_path.empty()) {
|
||||
e.real_path = r.real;
|
||||
} else {
|
||||
++ymir_conflicts;
|
||||
if (seen_conflicts.size() < 50 &&
|
||||
std::find(seen_conflicts.begin(), seen_conflicts.end(), suf) ==
|
||||
seen_conflicts.end()) {
|
||||
seen_conflicts.push_back(suf);
|
||||
conflict_keys.push_back(suf);
|
||||
}
|
||||
}
|
||||
e.all_candidates.push_back(r.real);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
std::string AssetResolver::resolve(const std::string& vp, std::string* reason) const {
|
||||
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
|
||||
@@ -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_root(resolve 拼回真实路径)
|
||||
std::string save_index() const;
|
||||
bool load_index(const std::string& assets_root, const std::string& text,
|
||||
std::string* err = nullptr);
|
||||
|
||||
// assets_root 下有 asset_index.txt 就 load,否则 build() 扫盘。
|
||||
// 读文件走 fmt::read_file(host 可注入 FileAccess -> res:// 可用)。
|
||||
bool build_or_load(const std::string& assets_root,
|
||||
const std::vector<std::string>& pack_priority = default_priority(),
|
||||
std::string* err = nullptr);
|
||||
|
||||
// 规范化:'\'->'/',去盘符,去开头 '/',小写,折叠 '//',处理 '.'/'..'。
|
||||
static std::string normalize(const std::string& virtual_path);
|
||||
|
||||
// 解析。成功返回真实路径;失败返回 "" 且(可选)写 reason。
|
||||
std::string resolve(const std::string& virtual_path, std::string* reason = nullptr) const;
|
||||
bool exists(const std::string& virtual_path) const;
|
||||
|
||||
// 所有已索引文件的相对路径(by_rel 的 real_path 去重)。PropertyRegistry::scan_list 用。
|
||||
std::vector<std::string> all_rel() const;
|
||||
|
||||
static std::vector<std::string> default_priority();
|
||||
};
|
||||
|
||||
} // namespace fmt
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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-up:yaw 绕 Y、pitch 绕 X、**roll 绕 Z = 竖直轴 = 朝向**。
|
||||
// areadata 单值旋转 → roll(`Area.cpp:820`)。这一版**未**做 Z-up→Y-up 轴转换,
|
||||
// 直接塞进 Godot Basis 会把「朝向」错当成绕 Godot Z 的翻滚 —— 放置物体请用 object_basis_godot()。
|
||||
Mat3 ypr_basis(double yaw_deg, double pitch_deg, double roll_deg);
|
||||
|
||||
// 同上,但已把旋转共轭到 **Godot Y-up 空间**(R_godot = C · R_m2 · C⁻¹,C = rotate(-90°,X))。
|
||||
// 可直接作为 Godot Basis 使用:物体的 Godot 变换 = Transform3D(object_basis_godot(...), g) * make_conv()。
|
||||
// roll(朝向)→ 绕 Godot +Y 的 heading,不再翻滚。行主序 3×3。
|
||||
Mat3 object_basis_godot(double yaw_deg, double pitch_deg, double roll_deg);
|
||||
|
||||
} // namespace fmt::m2coord
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,26 @@
|
||||
// setting.txt(ScriptType MapSetting)—— 地图全局设置。
|
||||
// 对标 GameLib/MapOutdoorLoad.cpp 的 setting 解析。SHINSOO §5.1。
|
||||
#pragma once
|
||||
#include <string>
|
||||
|
||||
namespace fmt {
|
||||
|
||||
struct MapSetting {
|
||||
int cell_scale = 200; // CellScale(cm/格),默认取 PRTerrainLib 常量
|
||||
double height_scale = 0.5; // HeightScale(raw 高度乘数)
|
||||
int view_radius = 0; // ViewRadius
|
||||
int map_size_x = 0; // MapSize 第 1 个数(区块列数)
|
||||
int map_size_y = 0; // MapSize 第 2 个数(区块行数)
|
||||
long base_position_x = 0; // BasePosition(全局世界基准,cm)
|
||||
long base_position_y = 0;
|
||||
std::string texture_set; // TextureSet 相对路径(原样,含反斜杠)
|
||||
std::string environment; // Environment(.msenv 名)
|
||||
|
||||
int chunk_count() const { return map_size_x * map_size_y; }
|
||||
};
|
||||
|
||||
// text = setting.txt 全文。缺关键字段 -> false + err。
|
||||
bool parse_map_setting(const std::string& text, MapSetting& out, std::string* err);
|
||||
bool parse_map_setting_file(const std::string& path, MapSetting& out, std::string* err);
|
||||
|
||||
} // namespace fmt
|
||||
@@ -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
|
||||
@@ -0,0 +1,31 @@
|
||||
// .msa —— Metin2 动作描述(文本,ScriptType MotionData)。M2 T1。
|
||||
#pragma once
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace fmt {
|
||||
|
||||
struct MotionEvent {
|
||||
int type = 0; // MotionEventType(10=effect, 4/5=sound, ...)
|
||||
float start_time = 0; // StartingTime
|
||||
std::string effect_file; // EffectFileName(type 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};// Accumulation(root motion 位移)
|
||||
std::vector<MotionEvent> events;
|
||||
bool has_loop_data = false; // LoopData 是片段循环,不等同于整段播放模式
|
||||
int motion_loop_count = 0; // -1 无限;原客户端仅在 >1 或 -1 时回绕片段
|
||||
bool loop_cancel_enable = false;
|
||||
float loop_start_time = 0;
|
||||
float loop_end_time = 0;
|
||||
};
|
||||
|
||||
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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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/*.pra(std::filesystem)。
|
||||
bool scan(const std::string& root, std::string* err);
|
||||
|
||||
// 同上,但文件清单外部给(PCK 里没法 recursive_directory_iterator)。
|
||||
// rel_paths 里挑属性扩展名的,逐个 read_file(root + "/" + rel) 解析。
|
||||
bool scan_list(const std::string& root, const std::vector<std::string>& rel_paths,
|
||||
std::string* err);
|
||||
};
|
||||
|
||||
} // namespace fmt
|
||||
@@ -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
|
||||
@@ -0,0 +1,30 @@
|
||||
// W2 —— tile.raw -> 每图层 258×258 alpha(splat 权重)。
|
||||
// 逐字节照 GameLib/AreaTerrain.cpp CTerrain::RAW_GenerateSplat:
|
||||
// texel 值 = TextureSet 运行时图层索引;
|
||||
// layer i 的 alpha = 0xFF 当 tile==i;当 tile>i 且 3×3 邻域有 ==i 时也 0xFF(1px 羽化);否则 0。
|
||||
// SHINSOO §9-W2。无 godot 依赖。
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
#include "terrain_files.h"
|
||||
|
||||
namespace fmt {
|
||||
|
||||
constexpr int SPLAT_RAW_XY = TILEMAP_RAW_XY; // 258
|
||||
|
||||
struct SplatLayer {
|
||||
int layer = 0; // TextureSet 运行时索引(1..N)
|
||||
std::vector<uint8_t> alpha; // 258*258,行主序
|
||||
int coverage = 0; // alpha==0xFF 的 texel 数(调试 / 决定是否提交)
|
||||
};
|
||||
|
||||
struct SplatSet {
|
||||
std::vector<SplatLayer> layers; // 只含在本区块出现过的图层,按索引升序
|
||||
std::vector<int> tile_histogram; // [runtime_layer_index] -> texel 数(长度 = max+1)
|
||||
};
|
||||
|
||||
// tile 来自 load_tile_map。runtime_layer_count = TextureSet.runtime_count()(含 index 0 空层)。
|
||||
void build_splat(const TileMap& tile, int runtime_layer_count, SplatSet& out);
|
||||
|
||||
} // namespace fmt
|
||||
+100
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,44 @@
|
||||
// 区块二进制:height.raw / tile.raw / attr.atr / water.wtr。
|
||||
// 真值来源:PRTerrainLib/Terrain.cpp(LoadHeightMap / RAW_LoadTileMap / LoadAttrMap /
|
||||
// LoadWaterMapFile)+ Terrain.h / TerrainType.h 常量。SHINSOO §5.3。
|
||||
//
|
||||
// XSIZE = 128
|
||||
// height.raw : 无头,u16 LE [131*131] (HEIGHTMAP_RAW = XSIZE+3)
|
||||
// tile.raw : 无头,u8 [258*258] (TILEMAP_RAW = XSIZE*2+2)
|
||||
// attr.atr : 6B 头 {u16 magic=2634, u16 w=256, u16 h=256} + u8 [256*256]
|
||||
// water.wtr : 7B 头 {u16 magic=5426, u16 w=128, u16 h=128, u8 layers}
|
||||
// + u8 [128*128] + layers×(u16 或 u32) 层高
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace fmt {
|
||||
|
||||
constexpr int HEIGHTMAP_RAW_XY = 131; // 128+3
|
||||
constexpr int TILEMAP_RAW_XY = 258; // 128*2+2
|
||||
constexpr int ATTRMAP_XY = 256; // 128*2
|
||||
constexpr int WATERMAP_XY = 128;
|
||||
constexpr uint16_t ATTR_MAGIC = 2634;
|
||||
constexpr uint16_t WATER_MAGIC = 5426;
|
||||
|
||||
struct HeightMap {
|
||||
std::vector<uint16_t> raw; // 131*131, 行主序(含 1 圈边界样本)
|
||||
uint16_t at(int sx, int sy) const { // 与 Terrain.h GetHeight 一致:+1 偏移
|
||||
return raw[(sy + 1) * HEIGHTMAP_RAW_XY + (sx + 1)];
|
||||
}
|
||||
};
|
||||
struct TileMap { std::vector<uint8_t> raw; }; // 258*258
|
||||
struct AttrMap { std::vector<uint8_t> data; }; // 256*256(去头)
|
||||
struct WaterMap {
|
||||
uint8_t layer_count = 0;
|
||||
std::vector<uint8_t> ids; // 128*128;0xFF = 无水
|
||||
std::vector<int32_t> heights; // layer_count 个
|
||||
};
|
||||
|
||||
bool load_height_map(const std::string& path, HeightMap& out, std::string* err);
|
||||
bool load_tile_map(const std::string& path, TileMap& out, std::string* err);
|
||||
bool load_attr_map(const std::string& path, AttrMap& out, std::string* err);
|
||||
bool load_water_map(const std::string& path, WaterMap& out, std::string* err);
|
||||
|
||||
} // namespace fmt
|
||||
@@ -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
|
||||
@@ -0,0 +1,38 @@
|
||||
// W1 —— 一个区块的地形几何(heightmap -> 顶点 / 法线 / 索引),纯数学、无 godot 依赖。
|
||||
// 三角划分与法线照 GameLib/AreaTerrain.cpp CTerrain::GetHeight / CalculateNormal。
|
||||
// SHINSOO §9-W1。
|
||||
//
|
||||
// 129×129 可见顶点,128×128 quad。每个 quad 对角线 = TL->BR(GetHeight 的 h1/h2)。
|
||||
// 顶点已做 Metin2(cm,Z-up) -> Godot(m,Y-up) 转换(m2_coord),并加了区块原点。
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
#include "terrain_files.h"
|
||||
|
||||
namespace fmt {
|
||||
|
||||
struct TerrainMesh {
|
||||
static constexpr int VERTS_XY = 129; // XSIZE+1
|
||||
static constexpr int QUADS_XY = 128; // XSIZE
|
||||
|
||||
std::vector<float> positions; // 3/vert,Godot 空间(米,Y-up),含区块原点
|
||||
std::vector<float> normals; // 3/vert,Godot 空间,单位向量
|
||||
std::vector<float> uvs; // 2/vert,区块内 [0,1](用于 splat / 地表贴图)
|
||||
std::vector<int32_t> indices; // 3/tri,6/quad
|
||||
float min_y = 0, max_y = 0; // Godot Y(高度)范围,做 custom_aabb / 相机用
|
||||
|
||||
int vertex_count() const { return VERTS_XY * VERTS_XY; }
|
||||
int index_count() const { return QUADS_XY * QUADS_XY * 6; }
|
||||
};
|
||||
|
||||
// height_scale 从 MapSetting 取(A1 = 0.5)。tile_x/tile_y 决定区块原点。
|
||||
// ccw_from_above=true:三角绕序在 Godot 里从上方看为逆时针(默认,若发现背面朝上则传 false)。
|
||||
void build_terrain_mesh(const HeightMap& hm, int tile_x, int tile_y, double height_scale,
|
||||
TerrainMesh& out, bool ccw_from_above = true);
|
||||
|
||||
// 与 CTerrain::GetHeight 一致的三角插值。lx/ly = 区块本地厘米([0, 128*200])。
|
||||
// 返回厘米高度(未转 Godot 单位)。
|
||||
double terrain_height_at(const HeightMap& hm, double lx_cm, double ly_cm, double height_scale);
|
||||
|
||||
} // namespace fmt
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
// Environment:A1.msenv 由虚拟路径解析
|
||||
fmt::Environment env;
|
||||
CHECK(fmt::parse_environment_file(assets + "/ETC/ymir work/environment/a1.msenv", env, &err),
|
||||
("live a1.msenv: " + err).c_str());
|
||||
CHECK(env.script_type == "EnvrionmentData", "live msenv script_type");
|
||||
CHECK(env.fog.enable && env.sky.gradient.size() >= 8, "live msenv fog + gradient list");
|
||||
|
||||
// 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user