完善客户端功能并同步差距文档

This commit is contained in:
shenlei
2026-09-03 18:40:01 +09:00
parent f93a172296
commit 13ccb8d02d
135 changed files with 21881 additions and 1259 deletions
+13
View File
@@ -4,6 +4,7 @@ add_library(xr_formats STATIC
textscript.cpp # M2 T1: Metin2 文本脚本 token 树解析(.msa / .msm 共用)
msm.cpp # 模型:base gr2 + 发型(RaceDataScript 子集)
msa.cpp # 动作:anim gr2 + duration + accumulation + 事件
combo_table.cpp # §3.5: playersettingmodule.py 的 ComboAttackNew -> PC 连击段表
m2_tokvec.cpp # W0: LoadMultipleTextData 等价(Start/End 块 → token 向量表)
m2_coord.cpp # W0: Metin2↔Godot 坐标 / 单位 / 区块编号(BACKLOG I5
@@ -27,11 +28,23 @@ if(BUILD_TESTING)
add_executable(formats_msa_test tests/msa_test.cpp)
target_link_libraries(formats_msa_test PRIVATE xrender::formats)
add_test(NAME formats.msa_loop_data COMMAND formats_msa_test)
if(DEFINED ENV{M2_ASSETS})
set_tests_properties(formats.msa_loop_data PROPERTIES
ENVIRONMENT "M2_ASSETS=$ENV{M2_ASSETS}")
endif()
add_executable(formats_msm_test tests/msm_test.cpp)
target_link_libraries(formats_msm_test PRIVATE xrender::formats)
add_test(NAME formats.msm_hair COMMAND formats_msm_test)
add_executable(formats_combo_table_test tests/combo_table_test.cpp)
target_link_libraries(formats_combo_table_test PRIVATE xrender::formats)
add_test(NAME formats.combo_table COMMAND formats_combo_table_test)
if(DEFINED ENV{M2_ASSETS})
set_tests_properties(formats.combo_table PROPERTIES
ENVIRONMENT "M2_ASSETS=$ENV{M2_ASSETS}")
endif()
add_executable(formats_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)
+186
View File
@@ -0,0 +1,186 @@
// M2 §3.5 —— playersettingmodule.py 的 ComboAttackNew 调用 -> PC 连击段表。
#include "combo_table.h"
#include "m2_tokvec.h" // fmt::read_file
#include <cctype>
#include <cstdlib>
namespace fmt {
const std::vector<uint16_t>* ComboTable::get(uint16_t motion_mode, uint16_t combo_type) const {
auto it = combos.find(make_combo_key(motion_mode, combo_type));
return it == combos.end() ? nullptr : &it->second;
}
const ComboTable& PlayerComboTables::klass(int combo_class) const {
static const ComboTable kEmpty;
if (combo_class < 0 || combo_class >= COMBO_CLASS_COUNT) return kEmpty;
return per_class[combo_class];
}
namespace {
std::string trim(const std::string& s) {
size_t a = 0, b = s.size();
while (a < b && std::isspace(static_cast<unsigned char>(s[a]))) ++a;
while (b > a && std::isspace(static_cast<unsigned char>(s[b - 1]))) --b;
return s.substr(a, b - a);
}
bool starts_with(const std::string& s, const char* p) {
return s.rfind(p, 0) == 0;
}
// chr.MOTION_MODE_XXX -> CRaceMotionData::EMode 值。
bool resolve_motion_mode(const std::string& suffix, long& out) {
static const std::map<std::string, long> kMap = {
{"RESERVED", 0}, {"GENERAL", 1}, {"ONEHAND_SWORD", 2}, {"TWOHAND_SWORD", 3},
{"DUALHAND_SWORD", 4}, {"BOW", 5}, {"FAN", 6}, {"BELL", 7}, {"FISHING", 8},
{"HORSE", 9}, {"HORSE_ONEHAND_SWORD", 10}, {"HORSE_TWOHAND_SWORD", 11},
{"HORSE_DUALHAND_SWORD", 12}, {"HORSE_BOW", 13}, {"HORSE_FAN", 14},
{"HORSE_BELL", 15}, {"WEDDING_DRESS", 16},
};
auto it = kMap.find(suffix);
if (it == kMap.end()) return false;
out = it->second;
return true;
}
// COMBO_TYPE_N / COMBO_INDEX_N -> N-1(两者都是从 1 起的人读编号,值从 0 起)。
bool resolve_one_based(const std::string& tail, long& out) {
if (tail.empty()) return false;
char* end = nullptr;
long n = std::strtol(tail.c_str(), &end, 10);
if (end == tail.c_str() || *end != '\0' || n < 1) return false;
out = n - 1;
return true;
}
// 一个实参 token -> 整数。认符号常量,也认裸整数。
bool resolve_token(const std::string& tok_in, long& out) {
const std::string tok = trim(tok_in);
if (tok.empty()) return false;
if (starts_with(tok, "chr.MOTION_MODE_")) {
return resolve_motion_mode(tok.substr(sizeof("chr.MOTION_MODE_") - 1), out);
}
if (starts_with(tok, "chr.MOTION_COMBO_ATTACK_")) {
if (!resolve_one_based(tok.substr(sizeof("chr.MOTION_COMBO_ATTACK_") - 1), out)) return false;
out = COMBO_NAME_COMBO_ATTACK_1 + out; // 0-based n -> 段号 14 + n
return true;
}
if (tok == "chr.MOTION_NORMAL_ATTACK") { out = COMBO_NAME_NORMAL_ATTACK; return true; }
if (starts_with(tok, "COMBO_TYPE_")) { return resolve_one_based(tok.substr(sizeof("COMBO_TYPE_") - 1), out); }
if (starts_with(tok, "COMBO_INDEX_")) { return resolve_one_based(tok.substr(sizeof("COMBO_INDEX_") - 1), out); }
// 裸整数
char* end = nullptr;
long n = std::strtol(tok.c_str(), &end, 10);
if (end == tok.c_str() || *end != '\0') return false;
out = n;
return true;
}
// "a, b, c)"call 名后的部分)-> 解析出的整数实参。分号里没有嵌套括号。
bool parse_call_args(const std::string& after_paren, std::vector<long>& out) {
size_t close = after_paren.find(')');
if (close == std::string::npos) return false;
const std::string inner = after_paren.substr(0, close);
out.clear();
size_t start = 0;
while (start <= inner.size()) {
size_t comma = inner.find(',', start);
std::string tok = inner.substr(start, comma == std::string::npos ? std::string::npos : comma - start);
tok = trim(tok);
if (!tok.empty()) {
long v = 0;
if (!resolve_token(tok, v)) return false;
out.push_back(v);
}
if (comma == std::string::npos) break;
start = comma + 1;
}
return true;
}
int class_from_def_line(const std::string& line) {
if (line.find("def __LoadGameWarriorEx") != std::string::npos) return COMBO_CLASS_WARRIOR;
if (line.find("def __LoadGameAssassinEx") != std::string::npos) return COMBO_CLASS_ASSASSIN;
if (line.find("def __LoadGameSuraEx") != std::string::npos) return COMBO_CLASS_SURA;
if (line.find("def __LoadGameShamanEx") != std::string::npos) return COMBO_CLASS_SHAMAN;
return -1;
}
} // namespace
bool parse_player_combo_tables(const std::string& py_text, PlayerComboTables& out, std::string* err) {
out = PlayerComboTables{};
int cur_class = -1;
size_t pos = 0;
const size_t n = py_text.size();
while (pos < n) {
size_t eol = py_text.find('\n', pos);
std::string line = py_text.substr(pos, eol == std::string::npos ? std::string::npos : eol - pos);
pos = (eol == std::string::npos) ? n : eol + 1;
const std::string t = trim(line);
if (t.empty() || t[0] == '#') continue;
// 顶层 def 换块:只有四个 __LoadGame<Class>Ex 有连击段调用,其它 def 复位。
if (starts_with(line, "def ")) {
cur_class = class_from_def_line(line);
continue;
}
if (cur_class < 0) continue;
const char* kReserve = "chrmgr.ReserveComboAttackNew(";
const char* kRegister = "chrmgr.RegisterComboAttackNew(";
size_t r = t.find(kReserve);
if (r != std::string::npos) {
std::vector<long> args;
if (!parse_call_args(t.substr(r + std::string(kReserve).size()), args) || args.size() != 3) {
if (err) *err = "combo_table: 无法解析 ReserveComboAttackNew: " + t;
return false;
}
const uint32_t key = make_combo_key(static_cast<uint16_t>(args[0]), static_cast<uint16_t>(args[1]));
const long count = args[2] < 0 ? 0 : args[2];
// CRaceData::ReserveComboAttack 用 map::insert —— 已存在的 key 不覆盖。
out.per_class[cur_class].combos.emplace(
key, std::vector<uint16_t>(static_cast<size_t>(count), 0));
continue;
}
size_t g = t.find(kRegister);
if (g != std::string::npos) {
std::vector<long> args;
if (!parse_call_args(t.substr(g + std::string(kRegister).size()), args) || args.size() != 4) {
if (err) *err = "combo_table: 无法解析 RegisterComboAttackNew: " + t;
return false;
}
const uint32_t key = make_combo_key(static_cast<uint16_t>(args[0]), static_cast<uint16_t>(args[1]));
const long idx = args[2];
const uint16_t motion = static_cast<uint16_t>(args[3]);
auto it = out.per_class[cur_class].combos.find(key);
if (it == out.per_class[cur_class].combos.end()) continue; // 未 Reserve:跳过
if (idx < 0 || static_cast<size_t>(idx) >= it->second.size()) continue; // 越界:跳过
it->second[static_cast<size_t>(idx)] = motion;
continue;
}
}
if (err) err->clear();
return true;
}
bool parse_player_combo_tables_file(const std::string& path, PlayerComboTables& out, std::string* err) {
std::string s;
if (!read_file(path, s)) {
if (err) *err = "combo_table: 打不开 " + path;
return false;
}
return parse_player_combo_tables(s, out, err);
}
} // namespace fmt
+92
View File
@@ -0,0 +1,92 @@
// combo_table —— PC 连击段表(CRaceData::TComboAttackDataMap 的等价)。M2 §3.5。
//
// 参考端的 PC 连击段表**不在** .msm 里(.msm 只有 BaseModelFileName + HairData)。
// 它由 playersettingmodule.py 的 chrmgr.ReserveComboAttackNew / RegisterComboAttackNew
// 调用在 CRaceData 上建起来(UserInterface/PythonCharacterManagerModule.cpp →
// CRaceData::ReserveComboAttack / RegisterComboAttackGameLib/RaceData.cpp)。
// 本解析器就把那批调用读成同一张表。
//
// NPC/怪物的连击段走另一条路(RaceManager.cpp __LoadRaceMotionList 读 motlist.txt
// 只在 MODE_GENERAL 下登记 NAME_COMBO_ATTACK_1/2/3,没有段号向量),不在这里。
#pragma once
#include <cstdint>
#include <map>
#include <string>
#include <vector>
namespace fmt {
// CRaceMotionData::EModechr.MOTION_MODE_*)。combo key 的高 16 位。
enum ComboMotionMode : uint16_t {
COMBO_MODE_RESERVED = 0,
COMBO_MODE_GENERAL = 1,
COMBO_MODE_ONEHAND_SWORD = 2,
COMBO_MODE_TWOHAND_SWORD = 3,
COMBO_MODE_DUALHAND_SWORD = 4,
COMBO_MODE_BOW = 5,
COMBO_MODE_FAN = 6,
COMBO_MODE_BELL = 7,
COMBO_MODE_FISHING = 8,
COMBO_MODE_HORSE = 9,
COMBO_MODE_HORSE_ONEHAND_SWORD = 10,
COMBO_MODE_HORSE_TWOHAND_SWORD = 11,
COMBO_MODE_HORSE_DUALHAND_SWORD = 12,
COMBO_MODE_HORSE_BOW = 13,
COMBO_MODE_HORSE_FAN = 14,
COMBO_MODE_HORSE_BELL = 15,
COMBO_MODE_WEDDING_DRESS = 16,
};
// CRaceMotionData::EName 的攻击段(chr.MOTION_NORMAL_ATTACK / chr.MOTION_COMBO_ATTACK_*)。
// ComboIndexVector 里存的就是这些段号。
enum : uint16_t {
COMBO_NAME_NORMAL_ATTACK = 13,
COMBO_NAME_COMBO_ATTACK_1 = 14,
COMBO_NAME_COMBO_ATTACK_8 = 21,
};
// RaceData.h: MAKE_COMBO_KEY(motion_mode, combo_type)
inline uint32_t make_combo_key(uint16_t motion_mode, uint16_t combo_type) {
return (static_cast<uint32_t>(motion_mode) << 16) | static_cast<uint32_t>(combo_type);
}
inline uint16_t combo_key_mode(uint32_t key) { return static_cast<uint16_t>((key >> 16) & 0xFFFF); }
inline uint16_t combo_key_type(uint32_t key) { return static_cast<uint16_t>(key & 0xFFFF); }
// playersettingmodule.py 按 __LoadGame<Class>Ex(race, path) 分四块,男女同一函数体
// (连击段表与性别无关),所以这里按职业收,不按 race。
enum ComboClass : int {
COMBO_CLASS_WARRIOR = 0,
COMBO_CLASS_ASSASSIN = 1,
COMBO_CLASS_SURA = 2,
COMBO_CLASS_SHAMAN = 3,
COMBO_CLASS_COUNT = 4,
};
// CRaceData::TComboAttackDataMap 的等价:MAKE_COMBO_KEY -> ComboIndexVector。
struct ComboTable {
// key = make_combo_key(mode, type)value = 有序的 COMBO_NAME_COMBO_ATTACK_* 段号,
// 下标即 dwComboArrayIndexm_dwcurComboIndex - 1)。
std::map<uint32_t, std::vector<uint16_t>> combos;
// GetComboDataPointer:命中返回段号向量指针,否则 nullptr。
const std::vector<uint16_t>* get(uint16_t motion_mode, uint16_t combo_type) const;
bool empty() const { return combos.empty(); }
size_t key_count() const { return combos.size(); }
};
struct PlayerComboTables {
ComboTable per_class[COMBO_CLASS_COUNT];
// 越界返回一张空表(不抛)。
const ComboTable& klass(int combo_class) const;
};
// 解析 playersettingmodule.py 里的 chrmgr.ReserveComboAttackNew / RegisterComboAttackNew
// 调用,按 def __LoadGame<Class>Ex 分块填 per_class。照 CRaceData 语义:
// Reserve -> combos[key] = vector(count, 0)(重复 key 不覆盖,同 std::map::insert
// Register -> combos[key][index] = motionkey 未 Reserve 或 index 越界则跳过)
// 认得的符号:chr.MOTION_MODE_*、chr.MOTION_COMBO_ATTACK_N=13+N)、
// chr.MOTION_NORMAL_ATTACK=13)、COMBO_TYPE_N=N-1)、COMBO_INDEX_N=N-1)、裸整数。
bool parse_player_combo_tables(const std::string& py_text, PlayerComboTables& out, std::string* err);
bool parse_player_combo_tables_file(const std::string& path, PlayerComboTables& out, std::string* err);
} // namespace fmt
+14
View File
@@ -140,6 +140,7 @@ bool parse_environment(const std::string& text, Environment& out, std::string* e
if (auto* l = root.kv("ScriptType")) out.script_type = l->size() > 1 ? (*l)[1] : "";
out.script_version = root.f("ScriptVersion", 1);
out.reserved = root.i("Reserved") != 0;
if (const Block* dl = root.child("DirectionalLight")) {
if (auto* d = dl->kv("Direction"))
@@ -163,15 +164,28 @@ bool parse_environment(const std::string& text, Environment& out, std::string* e
}
if (const Block* fg = root.child("Fog")) {
out.fog.fog_level = fg->i("foglevel", 0);
out.fog.density_fog = fg->i("IsDensity") != 0;
out.fog.near_distance = fg->f("NearDistance", 1);
out.fog.far_distance = fg->f("FarDistance", 1);
out.fog.color = rgba(fg->kv("Color"));
out.fog.enable = (fg->kv("Enable") && fg->i("Enable") != 0) || out.fog.fog_level > 0;
}
if (const Block* flt = root.child("Filter")) {
out.filter.enable = flt->i("Enable") != 0;
out.filter.color = rgba(flt->kv("Color"));
out.filter.alpha_src = flt->i("AlphaSrc", out.filter.alpha_src);
out.filter.alpha_dest = flt->i("AlphaDest", out.filter.alpha_dest);
}
if (const Block* sb = root.child("SkyBox")) {
out.sky.texture_render_mode = sb->i("BTextureRenderMode") != 0;
for (int k = 0; k < 3; ++k) out.sky.scale[k] = sb->f("Scale", k + 1);
out.sky.gradient_level_upper = sb->i("GradientLevelUpper");
out.sky.gradient_level_lower = sb->i("GradientLevelLower");
static constexpr const char* kFaceKeys[6] = {
"FrontFaceFileName", "BackFaceFileName", "LeftFaceFileName",
"RightFaceFileName", "TopFaceFileName", "BottomFaceFileName"};
for (int k = 0; k < 6; ++k)
out.sky.face_textures[k] = sb->s(kFaceKeys[k]);
for (int k = 0; k < 2; ++k) {
out.sky.cloud_scale[k] = sb->f("CloudScale", k + 1);
out.sky.cloud_texture_scale[k] = sb->f("CloudTextureScale", k + 1);
+22 -10
View File
@@ -13,26 +13,36 @@ using Vec3f = std::array<float, 3>;
using Vec2f = std::array<float, 2>;
struct EnvDirLight {
Vec3f direction{{0, 0, 0}};
Vec3f direction{{0.5f, 0.5f, -0.5f}};
bool bg_enable = false, ch_enable = false;
Rgba bg_diffuse{{1, 1, 1, 1}}, bg_ambient{{0, 0, 0, 1}};
Rgba ch_diffuse{{1, 1, 1, 1}}, ch_ambient{{0, 0, 0, 1}};
Rgba bg_diffuse{{1, 1, 1, 1}}, bg_ambient{{0.5f, 0.5f, 0.5f, 1}};
Rgba ch_diffuse{{1, 1, 1, 1}}, ch_ambient{{0.5f, 0.5f, 0.5f, 1}};
};
struct EnvMaterial {
Rgba diffuse{{1, 1, 1, 1}}, ambient{{1, 1, 1, 1}}, emissive{{0, 0, 0, 1}};
Rgba diffuse{{0.8f, 0.8f, 0.8f, 1}}, ambient{{0.8f, 0.8f, 0.8f, 1}},
emissive{{0.8f, 0.8f, 0.8f, 1}};
};
struct EnvFog {
// 两种写法:老版 `Enable`/`NearDistance`/`FarDistance`A1 实盘用 `foglevel`0=关,>0=开)。
bool enable = false; // Enable != 0 || fog_level > 0
bool density_fog = false; // IsDensity(参考端保留,当前 Godot 用深度雾)
int fog_level = 0; // foglevel
float near_distance = 0, far_distance = 0;
Rgba color{{1, 1, 1, 1}};
float near_distance = 25600.0f * 0.5f, far_distance = 25600.0f * 0.7f;
Rgba color{{0.5f, 0.5f, 0.5f, 1}};
};
struct EnvFilter {
bool enable = false;
Rgba color{{0.3f, 0.1f, 0.1f, 0}};
int alpha_src = 1; // D3DBLEND_ONE
int alpha_dest = 1; // D3DBLEND_ONE
};
struct EnvSkyBox {
Vec3f scale{{1, 1, 1}};
Vec3f scale{{3500, 3500, 3500}};
bool texture_render_mode = false;
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::array<std::string, 6> face_textures{}; // front/back/left/right/top/bottom
Vec2f cloud_scale{{200000, 200000}}, cloud_texture_scale{{4, 4}}, cloud_speed{{0.001f, 0.001f}};
float cloud_height = 30000;
std::string cloud_texture;
std::vector<Rgba> cloud_color; // List CloudColor
std::vector<Rgba> gradient; // List Gradient(成对 = 一段渐变的上下色)
@@ -40,7 +50,7 @@ struct EnvSkyBox {
struct EnvLensFlare {
bool enable = false, main_flare_enable = false;
Rgba brightness_color{{1, 1, 1, 1}};
float max_brightness = 0, main_flare_size = 0;
float max_brightness = 1, main_flare_size = 0.2f;
std::string main_flare_texture;
};
@@ -50,8 +60,10 @@ struct Environment {
EnvDirLight dir_light;
EnvMaterial material;
EnvFog fog;
EnvFilter filter;
EnvSkyBox sky;
EnvLensFlare lens_flare;
bool reserved = false;
};
bool parse_environment(const std::string& text, Environment& out, std::string* err);
+66
View File
@@ -20,6 +20,70 @@ static void loop_data(const Node& root, Msa& out) {
}
}
// `List HitPosition { t lastXYZ XYZ t lastXYZ XYZ ... }` —— 7 个一组切成采样点。
static void hit_positions(const Node& win, std::vector<Msa::HitSample>& out) {
const Node* lst = win.group("HitPosition");
if (!lst || lst->lines.empty()) return;
const std::vector<std::string>& t = lst->lines[0];
for (size_t i = 0; i + 7 <= t.size(); i += 7) {
Msa::HitSample s;
s.time = float(std::atof(t[i].c_str()));
for (int k = 0; k < 3; ++k) s.last_pos[k] = float(std::atof(t[i + 1 + k].c_str()));
for (int k = 0; k < 3; ++k) s.pos[k] = float(std::atof(t[i + 4 + k].c_str()));
out.push_back(s);
}
}
// 一个命中窗(NRaceData::THitData)。老式写法里 win == AttackingData 组本身。
static Msa::HitWindow hit_window(const Node& win) {
Msa::HitWindow w;
w.start_time = win.num("AttackingStartTime");
w.end_time = win.num("AttackingEndTime");
w.bone_name = win.str("AttackingBone");
w.weapon_length = win.num("WeaponLength");
hit_positions(win, w.samples);
return w;
}
// Group ComboInputData / Group AttackingData —— 照 EterGrnLib CGrannyMotion::LoadMotionData
// 与 GameLib NRaceData::LoadMotionAttackData 的字段名读取;缺组时保持默认值。
static void combat_data(const Node& root, Msa& out) {
if (const Node* c = root.group("ComboInputData")) {
out.has_combo_input = true;
out.combo_pre_input_time = c->num("PreInputTime");
out.combo_direct_input_time = c->num("DirectInputTime");
out.combo_input_limit_time = c->num("InputLimitTime");
out.combo_link_time = c->num("LinkTime");
}
if (const Node* a = root.group("AttackingData")) {
out.has_attacking_data = true;
out.attacking_type = a->inum("AttackType", a->inum("AttackingType", 0));
out.motion_type = a->inum("MotionType", out.attacking_type);
out.hitting_type = a->inum("HittingType", 0);
out.stiffen_time = a->num("StiffenTime");
out.invisible_time = a->num("InvisibleTime");
out.external_force = a->num("ExternalForce");
out.hit_limit_count = a->inum("HitLimitCount", 0);
// 新式:HitDataCount N + Group HitData00..0N;老式:无 HitDataCount,组本身即窗。
bool has_children = false;
for (const Node& g : a->groups) {
const std::string& n = g.name;
if (n.size() >= 7 && (n.compare(0, 7, "HitData") == 0 || n.compare(0, 7, "hitdata") == 0)) {
out.hit_windows.push_back(hit_window(g));
has_children = true;
}
}
if (!has_children)
out.hit_windows.push_back(hit_window(*a));
if (!out.hit_windows.empty()) {
out.attack_start_time = out.hit_windows.front().start_time;
out.attack_end_time = out.hit_windows.front().end_time;
}
}
}
bool parse_msa(const std::string& text, Msa& out, std::string* err) {
Node root;
if (!parse_textscript(text, root, err)) return false;
@@ -29,6 +93,7 @@ bool parse_msa(const std::string& text, Msa& out, std::string* err) {
out.duration = root.num("MotionDuration");
vec3(root.find("Accumulation"), out.accumulation);
loop_data(root, out);
combat_data(root, out);
if (const Node* med = root.group("MotionEventData")) {
int count = med->inum("MotionEventDataCount", int(med->groups.size()));
@@ -57,6 +122,7 @@ bool parse_msa_file(const std::string& path, Msa& out, std::string* err) {
out.duration = root.num("MotionDuration");
vec3(root.find("Accumulation"), out.accumulation);
loop_data(root, out);
combat_data(root, out);
if (const Node* med = root.group("MotionEventData"))
for (const Node& ev : med->groups) {
MotionEvent e;
+45
View File
@@ -23,6 +23,51 @@ struct Msa {
bool loop_cancel_enable = false;
float loop_start_time = 0;
float loop_end_time = 0;
// Group ComboInputData —— 连击输入时间窗(EterGrnLib CGrannyMotion::LoadMotionData →
// CRaceMotionData::TComboInputData)。攻击节奏来自这里,不是硬编码间隔。
// PreInputTime → fInputStartTime 连击输入窗开始(可提前缓存下一击)
// DirectInputTime→ fNextComboTime 直接输入阈值 / 下一段连击起点(= 普攻节奏)
// InputLimitTime → fInputEndTime 连击输入窗关闭(超时归零)
// LinkTime → fComboLinkTime 段间衔接时间
bool has_combo_input = false;
float combo_pre_input_time = 0;
float combo_direct_input_time = 0;
float combo_input_limit_time = 0;
float combo_link_time = 0;
// Group AttackingData —— 命中判定窗(GameLib GameType.cpp NRaceData::TMotionAttackData)。
// 两种写法:老式(组内直接 AttackingStartTime/…/List HitPosition,无 HitDataCount=
// 单个命中窗;新式(AttackType + MotionType + HitDataCount N + Group HitData00..0N=
// N 个命中窗(多段挥击 / 旋风斩)。命中帧 / 硬直 / 无敌帧由动作数据驱动。
bool has_attacking_data = false;
int attacking_type = 0; // AttackType / AttackingTypeSAttackData::iAttackType
int motion_type = 0; // MotionType(缺省回退到 attacking_type
int hitting_type = 0; // HittingType
float stiffen_time = 0; // StiffenTime(受击硬直,喂给 §3.7 / 受击方)
float invisible_time = 0; // InvisibleTime(攻击者无敌帧)
float external_force = 0; // ExternalForce(击退力度)
int hit_limit_count = 0; // HitLimitCount0 = 不限)
// HitPosition 单个采样:time + 上一帧位置(xyz) + 当前帧位置(xyz),用于挥击扫掠球判定。
struct HitSample {
float time = 0;
float last_pos[3] = {0, 0, 0};
float pos[3] = {0, 0, 0};
};
// 一个命中窗(NRaceData::THitData)。
struct HitWindow {
float start_time = 0; // AttackingStartTime
float end_time = 0; // AttackingEndTime
std::string bone_name; // AttackingBone
float weapon_length = 0; // WeaponLength
std::vector<HitSample> samples; // List HitPosition
};
std::vector<HitWindow> hit_windows; // = THitDataContainer;老式写法长度为 1
// 兼容旧调用方:第一个命中窗的起止时间(无命中窗时为 0)。
float attack_start_time = 0;
float attack_end_time = 0;
};
bool parse_msa(const std::string& text, Msa& out, std::string* err);
+142
View File
@@ -0,0 +1,142 @@
// M2 §3.5 —— playersettingmodule.py 的 ComboAttackNew 调用 -> PC 连击段表。
// 照 GameLib/RaceData.cpp CRaceData::ReserveComboAttack / RegisterComboAttack 语义。
#include <combo_table.h>
#include <cstdio>
#include <cstdlib>
#include <string>
using namespace fmt;
static int g_fail = 0;
#define CHECK(c, msg) \
do { \
if (!(c)) { \
std::fprintf(stderr, "FAIL: %s\n", (msg)); \
++g_fail; \
} \
} while (0)
// warrior_m/_w 共用的 __LoadGameWarriorEx 片段 + assassin 的 DUALHAND(男限),
// 外加一个非 Ex 的 def 用来验证块复位。缩进 / tab 混用照原文。
static const char *kPy = R"PY(
COMBO_TYPE_1 = 0
COMBO_TYPE_2 = 1
COMBO_TYPE_3 = 2
COMBO_INDEX_1 = 0
COMBO_INDEX_6 = 5
def __LoadGameWarriorEx(race, path):
chrmgr.SelectRace(race)
chrmgr.ReserveComboAttackNew(chr.MOTION_MODE_GENERAL, COMBO_TYPE_1, 1)
chrmgr.RegisterComboAttackNew(chr.MOTION_MODE_GENERAL, COMBO_TYPE_1, COMBO_INDEX_1, chr.MOTION_COMBO_ATTACK_1)
## Combo Type 1
chrmgr.ReserveComboAttackNew(chr.MOTION_MODE_ONEHAND_SWORD, COMBO_TYPE_1, 4)
chrmgr.RegisterComboAttackNew(chr.MOTION_MODE_ONEHAND_SWORD, COMBO_TYPE_1, COMBO_INDEX_1, chr.MOTION_COMBO_ATTACK_1)
chrmgr.RegisterComboAttackNew(chr.MOTION_MODE_ONEHAND_SWORD, COMBO_TYPE_1, COMBO_INDEX_2, chr.MOTION_COMBO_ATTACK_2)
chrmgr.RegisterComboAttackNew(chr.MOTION_MODE_ONEHAND_SWORD, COMBO_TYPE_1, COMBO_INDEX_3, chr.MOTION_COMBO_ATTACK_3)
chrmgr.RegisterComboAttackNew(chr.MOTION_MODE_ONEHAND_SWORD, COMBO_TYPE_1, COMBO_INDEX_4, chr.MOTION_COMBO_ATTACK_4)
## Combo Type 2 —— 段号跳到 5 / 7(非连续,验证按下标写而非追加)
chrmgr.ReserveComboAttackNew(chr.MOTION_MODE_ONEHAND_SWORD, COMBO_TYPE_2, 5)
chrmgr.RegisterComboAttackNew(chr.MOTION_MODE_ONEHAND_SWORD, COMBO_TYPE_2, COMBO_INDEX_1, chr.MOTION_COMBO_ATTACK_1)
chrmgr.RegisterComboAttackNew(chr.MOTION_MODE_ONEHAND_SWORD, COMBO_TYPE_2, COMBO_INDEX_2, chr.MOTION_COMBO_ATTACK_2)
chrmgr.RegisterComboAttackNew(chr.MOTION_MODE_ONEHAND_SWORD, COMBO_TYPE_2, COMBO_INDEX_3, chr.MOTION_COMBO_ATTACK_3)
chrmgr.RegisterComboAttackNew(chr.MOTION_MODE_ONEHAND_SWORD, COMBO_TYPE_2, COMBO_INDEX_4, chr.MOTION_COMBO_ATTACK_5)
chrmgr.RegisterComboAttackNew(chr.MOTION_MODE_ONEHAND_SWORD, COMBO_TYPE_2, COMBO_INDEX_5, chr.MOTION_COMBO_ATTACK_7)
# 越界的 Register(下标 5 >= size 5):照参考端 AssertLog + return,忽略
chrmgr.RegisterComboAttackNew(chr.MOTION_MODE_ONEHAND_SWORD, COMBO_TYPE_2, 5, chr.MOTION_COMBO_ATTACK_8)
# 对已存在 key 的重复 Reservemap::insert 不覆盖,原向量保留
chrmgr.ReserveComboAttackNew(chr.MOTION_MODE_ONEHAND_SWORD, COMBO_TYPE_2, 99)
def __LoadGameAssassinEx(race, path):
chrmgr.SelectRace(race)
chrmgr.ReserveComboAttackNew(chr.MOTION_MODE_DUALHAND_SWORD, COMBO_TYPE_3, 6)
chrmgr.RegisterComboAttackNew(chr.MOTION_MODE_DUALHAND_SWORD, COMBO_TYPE_3, COMBO_INDEX_6, chr.MOTION_COMBO_ATTACK_8)
def __LoadGameNPC():
# 块外的调用不该落到任何职业里
chrmgr.ReserveComboAttackNew(chr.MOTION_MODE_GENERAL, COMBO_TYPE_1, 3)
)PY";
static void check_vec(const std::vector<uint16_t> *v, std::initializer_list<uint16_t> want, const char *msg) {
if (!v) { std::fprintf(stderr, "FAIL: %s (nullptr)\n", msg); ++g_fail; return; }
if (v->size() != want.size()) { std::fprintf(stderr, "FAIL: %s (size %zu != %zu)\n", msg, v->size(), want.size()); ++g_fail; return; }
size_t i = 0;
for (uint16_t w : want) {
if ((*v)[i] != w) { std::fprintf(stderr, "FAIL: %s ([%zu]=%u != %u)\n", msg, i, (*v)[i], w); ++g_fail; return; }
++i;
}
}
int main() {
// ---- key 编码:RaceData.h MAKE_COMBO_KEY -------------------------------
CHECK(make_combo_key(COMBO_MODE_ONEHAND_SWORD, 1) == ((2u << 16) | 1u), "make_combo_key");
CHECK(combo_key_mode(make_combo_key(5, 2)) == 5 && combo_key_type(make_combo_key(5, 2)) == 2, "combo_key split");
// ---- 解析 ------------------------------------------------------------
PlayerComboTables tbl;
std::string err;
if (!parse_player_combo_tables(kPy, tbl, &err)) {
std::fprintf(stderr, "parse failed: %s\n", err.c_str());
return 1;
}
const ComboTable &w = tbl.klass(COMBO_CLASS_WARRIOR);
const ComboTable &a = tbl.klass(COMBO_CLASS_ASSASSIN);
const ComboTable &s = tbl.klass(COMBO_CLASS_SURA);
// GENERAL / type1 —— 单段普攻连击(NAME_COMBO_ATTACK_1 = 14
check_vec(w.get(COMBO_MODE_GENERAL, 0), {14}, "warrior GENERAL type1 = [14]");
// ONEHAND_SWORD / type1 —— 连续 4 段 14..17
check_vec(w.get(COMBO_MODE_ONEHAND_SWORD, 0), {14, 15, 16, 17}, "warrior 1H type1 = [14,15,16,17]");
// ONEHAND_SWORD / type2 —— 5 段,末两段跳到 18 / 20COMBO_ATTACK_5 / _7
check_vec(w.get(COMBO_MODE_ONEHAND_SWORD, 1), {14, 15, 16, 18, 20}, "warrior 1H type2 = [14,15,16,18,20]");
// 越界 Register 被忽略;重复 Reserve 不覆盖(size 仍是 5,不是 99)
CHECK(w.get(COMBO_MODE_ONEHAND_SWORD, 1)->size() == 5, "dup Reserve ignored / OOB Register ignored");
// type3 从没 Reserve 过 -> GetComboDataPointer 应为 nullptr
CHECK(w.get(COMBO_MODE_ONEHAND_SWORD, 2) == nullptr, "warrior 1H type3 absent -> nullptr");
// 职业隔离:DUALHAND 只在 assassin 块里;warrior/sura 没有
check_vec(a.get(COMBO_MODE_DUALHAND_SWORD, 2), {0, 0, 0, 0, 0, 21}, "assassin DUAL type3 idx5 = COMBO_ATTACK_8(21)");
CHECK(w.get(COMBO_MODE_DUALHAND_SWORD, 2) == nullptr, "warrior has no DUALHAND table");
CHECK(s.empty(), "sura block absent in snippet -> empty table");
// 块外(__LoadGameNPC)的 Reserve 不该落进任何职业
size_t total = 0;
for (int c = 0; c < COMBO_CLASS_COUNT; ++c) total += tbl.klass(c).key_count();
CHECK(total == 4, "only the 4 in-Ex-block combo keys registered (2 warrior 1H + 1 warrior GENERAL + 1 assassin DUAL)");
// 越界职业索引 -> 空表,不崩
CHECK(tbl.klass(-1).empty() && tbl.klass(99).empty(), "out-of-range class -> empty");
// ---- 可选:真实 playersettingmodule.py(设了 M2_ASSETS 才跑)----------
if (const char *root = std::getenv("M2_ASSETS")) {
const std::string path = std::string(root) + "/root/playersettingmodule.py";
PlayerComboTables live;
std::string lerr;
if (parse_player_combo_tables_file(path, live, &lerr)) {
check_vec(live.klass(COMBO_CLASS_WARRIOR).get(COMBO_MODE_ONEHAND_SWORD, 0),
{14, 15, 16, 17}, "live warrior 1H type1");
// sura ONEHAND type3 末段回到 COMBO_ATTACK_4 (17)
const auto *sv = live.klass(COMBO_CLASS_SURA).get(COMBO_MODE_ONEHAND_SWORD, 2);
CHECK(sv && sv->size() == 6 && sv->back() == 17, "live sura 1H type3 tail = COMBO_ATTACK_4");
// shaman FAN type1 = 4 连续段
check_vec(live.klass(COMBO_CLASS_SHAMAN).get(COMBO_MODE_FAN, 0),
{14, 15, 16, 17}, "live shaman FAN type1");
std::printf("live playersettingmodule.py: OK\n");
} else {
std::fprintf(stderr, "live parse skipped: %s\n", lerr.c_str());
}
}
if (g_fail == 0) {
std::printf("PASS: formats_combo_table_test (§3.5 PC 连击段表)\n");
}
return g_fail == 0 ? 0 : 1;
}
+27
View File
@@ -199,12 +199,22 @@ Group DirectionalLight
Group Fog
{
foglevel 6
IsDensity 1
Color 0.69 0.74 0.83 1.0
}
Group Filter
{
Enable 1
Color 0.1 0.2 0.3 0.4
AlphaSrc 5
AlphaDest 6
}
Group SkyBox
{
BTextureRenderMode 1
Scale 3500.0 3500.0 3500.0
CloudTextureFileName "d:/ymir work/environment/clouds_zone01.tga"
FrontFaceFileName "d:/ymir work/environment/skybox/front.dds"
List Gradient
{
0.09 0.28 0.65 0.0
@@ -221,6 +231,12 @@ Group SkyBox
CHECK(e.dir_light.bg_enable && e.dir_light.ch_enable, "env bg/ch enable");
CHECK(std::fabs(e.dir_light.bg_diffuse[1] - 0.97f) < 1e-3f, "env bg diffuse");
CHECK(e.fog.enable && e.fog.fog_level == 6, "env fog (foglevel form)");
CHECK(e.fog.density_fog, "env density fog flag");
CHECK(e.filter.enable && e.filter.alpha_src == 5 && e.filter.alpha_dest == 6,
"env screen filter fields");
CHECK(std::fabs(e.filter.color[2] - 0.3f) < 1e-3f, "env screen filter color");
CHECK(e.sky.texture_render_mode && e.sky.face_textures[0].find("front.dds") != std::string::npos,
"env skybox texture mode + front face");
CHECK(std::fabs(e.sky.scale[0] - 3500.0f) < 1e-1f, "env skybox scale");
CHECK(e.sky.cloud_texture.find("clouds_zone01.tga") != std::string::npos, "env cloud tex path");
CHECK(e.sky.gradient.size() == 3, "env List Gradient rows");
@@ -452,6 +468,17 @@ static void test_live(const std::string& assets) {
CHECK(env.script_type == "EnvrionmentData", "live msenv script_type");
CHECK(env.fog.enable && env.sky.gradient.size() >= 8, "live msenv fog + gradient list");
fmt::Environment trent;
CHECK(fmt::parse_environment_file(
assets + "/season2/season2/metin2_map_trent02_a/trent02.msenv", trent, &err),
("live trent02.msenv: " + err).c_str());
CHECK(trent.dir_light.ch_enable && trent.dir_light.ch_ambient[0] > 0.39f,
"live trent character light");
CHECK(!trent.filter.enable && trent.filter.alpha_src == 2 && trent.filter.alpha_dest == 2,
"live trent filter fields");
CHECK(trent.sky.cloud_color.size() == 2 && trent.lens_flare.enable,
"live trent cloud gradient + lens flare");
// Property CRC 注册表
fmt::PropertyRegistry reg;
CHECK(reg.scan(assets + "/Property", &err), ("live Property scan: " + err).c_str());
+197
View File
@@ -30,5 +30,202 @@ Group LoopData
std::fprintf(stderr, "LoopData fields were not preserved\n");
return 1;
}
// A motion with no ComboInputData / AttackingData leaves the flags clear.
if (msa.has_combo_input || msa.has_attacking_data) {
std::fprintf(stderr, "combat groups reported present when absent\n");
return 1;
}
// CLIENT-GAP §3.3 / §3.5 —— real onehand_sword/combo_01.msa field values.
const std::string combo = R"(
ScriptType MotionData
MotionFileName "d:/ymir work/pc/warrior/onehand_sword/combo_01.gr2"
MotionDuration 1.000000
Accumulation 0.00 -131.76 0.00
Group ComboInputData
{
PreInputTime 0.167094
DirectInputTime 0.533333
InputLimitTime 0.602564
LinkTime 0.058889
}
Group AttackingData
{
AttackingType 2
HittingType 2
StiffenTime 0.0
InvisibleTime 0.100000
ExternalForce 8.000000
AttackingStartTime 0.192308
AttackingEndTime 0.315385
}
)";
fmt::Msa cm;
if (!fmt::parse_msa(combo, cm, &err)) {
std::fprintf(stderr, "combo parse failed: %s\n", err.c_str());
return 1;
}
const bool combo_ok = cm.has_combo_input &&
std::fabs(cm.combo_pre_input_time - 0.167094f) < 1e-5f &&
std::fabs(cm.combo_direct_input_time - 0.533333f) < 1e-5f &&
std::fabs(cm.combo_input_limit_time - 0.602564f) < 1e-5f &&
std::fabs(cm.combo_link_time - 0.058889f) < 1e-5f;
const bool atk_ok = cm.has_attacking_data && cm.attacking_type == 2 && cm.hitting_type == 2 &&
std::fabs(cm.attack_start_time - 0.192308f) < 1e-5f &&
std::fabs(cm.attack_end_time - 0.315385f) < 1e-5f &&
std::fabs(cm.invisible_time - 0.1f) < 1e-5f &&
std::fabs(cm.external_force - 8.0f) < 1e-5f;
if (!combo_ok || !atk_ok) {
std::fprintf(stderr, "ComboInputData / AttackingData fields were not preserved\n");
return 1;
}
// §3.5 修改 1 —— 老式内联写法(无 HitDataCount):单个命中窗,含 List HitPosition 采样。
const std::string inl = R"(
ScriptType MotionData
MotionFileName "d:/ymir work/pc/warrior/general/attack.gr2"
MotionDuration 1.0
Group AttackingData
{
AttackingType 1
HittingType 2
StiffenTime 0.0
InvisibleTime 0.5
ExternalForce 0.0
AttackingStartTime 0.456410
AttackingEndTime 0.597436
AttackingBone "equip_right_hand"
WeaponLength 0.0
List HitPosition
{
0.456410 1.0 2.0 3.0 4.0 5.0 6.0
0.464744 7.0 8.0 9.0 10.0 11.0 12.0
}
}
)";
fmt::Msa im;
if (!fmt::parse_msa(inl, im, &err)) {
std::fprintf(stderr, "inline attacking parse failed: %s\n", err.c_str());
return 1;
}
if (im.hit_windows.size() != 1 || im.hit_windows[0].samples.size() != 2 ||
im.hit_windows[0].bone_name != "equip_right_hand" ||
std::fabs(im.hit_windows[0].start_time - 0.456410f) > 1e-5f ||
std::fabs(im.hit_windows[0].end_time - 0.597436f) > 1e-5f ||
std::fabs(im.attack_start_time - 0.456410f) > 1e-5f ||
std::fabs(im.hit_windows[0].samples[1].time - 0.464744f) > 1e-5f ||
std::fabs(im.hit_windows[0].samples[1].last_pos[0] - 7.0f) > 1e-5f ||
std::fabs(im.hit_windows[0].samples[1].pos[2] - 12.0f) > 1e-5f) {
std::fprintf(stderr, "inline HitData window / HitPosition samples not preserved\n");
return 1;
}
// §3.5 修改 1 —— 新式写法:AttackType + MotionType + HitDataCount N + Group HitData0N。
const std::string multi = R"(
ScriptType MotionData
MotionFileName "d:/ymir work/pc/warrior/twohand_sword/combo_05.gr2"
MotionDuration 1.6
Group AttackingData
{
AttackType 0
HittingType 2
StiffenTime 0.0
InvisibleTime 0.2
ExternalForce 5.0
HitLimitCount 3
MotionType 2
HitDataCount 2
Group HitData00
{
AttackingStartTime 0.455475
AttackingEndTime 0.759124
AttackingBone "equip_right_hand"
WeaponLength 150.0
List HitPosition
{
0.455475 54.5 -30.5 71.1 89.9 105.7 19.4
}
}
Group HitData01
{
AttackingStartTime 0.9
AttackingEndTime 1.1
AttackingBone "equip_left_hand"
WeaponLength 120.0
}
}
)";
fmt::Msa mm;
if (!fmt::parse_msa(multi, mm, &err)) {
std::fprintf(stderr, "multi HitData parse failed: %s\n", err.c_str());
return 1;
}
if (mm.attacking_type != 0 || mm.motion_type != 2 || mm.hit_limit_count != 3 ||
mm.hit_windows.size() != 2 ||
std::fabs(mm.hit_windows[0].start_time - 0.455475f) > 1e-5f ||
mm.hit_windows[0].samples.size() != 1 ||
mm.hit_windows[1].bone_name != "equip_left_hand" ||
std::fabs(mm.hit_windows[1].end_time - 1.1f) > 1e-5f ||
mm.hit_windows[1].samples.size() != 0 ||
std::fabs(mm.attack_start_time - 0.455475f) > 1e-5f) {
std::fprintf(stderr, "multi HitData windows not preserved\n");
return 1;
}
// ---- 可选:真实 PC .msa(设了 M2_ASSETS 才跑)----------------------------
if (const char* root = std::getenv("M2_ASSETS")) {
const std::string base = std::string(root) + "/PC/ymir work/pc/warrior/";
fmt::Msa a1;
if (fmt::parse_msa_file(base + "general/attack.msa", a1, &err)) {
if (a1.hit_windows.size() != 1 || a1.hit_windows[0].bone_name != "equip_right_hand" ||
std::fabs(a1.hit_windows[0].start_time - 0.456410f) > 1e-4f ||
std::fabs(a1.hit_windows[0].end_time - 0.597436f) > 1e-4f ||
a1.hit_windows[0].samples.size() != 17) {
std::fprintf(stderr, "live general/attack.msa window/samples wrong "
"(windows=%zu samples=%zu)\n", a1.hit_windows.size(),
a1.hit_windows.empty() ? 0 : a1.hit_windows[0].samples.size());
return 1;
}
}
fmt::Msa c5;
if (fmt::parse_msa_file(base + "twohand_sword/combo_05.msa", c5, &err)) {
if (c5.hit_windows.size() != 1 || c5.motion_type != 2 ||
std::fabs(c5.hit_windows[0].weapon_length - 150.0f) > 1e-3f ||
std::fabs(c5.hit_windows[0].start_time - 0.455475f) > 1e-4f ||
c5.hit_windows[0].samples.size() != 37) {
std::fprintf(stderr, "live twohand_sword/combo_05.msa wrong "
"(windows=%zu samples=%zu)\n", c5.hit_windows.size(),
c5.hit_windows.empty() ? 0 : c5.hit_windows[0].samples.size());
return 1;
}
}
// 真正的双持双命中窗(右手窗 + 左手窗)。
const std::string adh = std::string(root) + "/PC/ymir work/pc/assassin/dualhand_sword/combo_01.msa";
fmt::Msa dh;
if (fmt::parse_msa_file(adh, dh, &err)) {
if (dh.hit_windows.size() != 2 ||
dh.hit_windows[0].bone_name != "Bip01 R Hand" ||
dh.hit_windows[1].bone_name != "Bip01 L Hand" ||
dh.hit_windows[0].samples.size() != 13 ||
dh.hit_windows[1].samples.size() != 13 ||
std::fabs(dh.hit_windows[1].start_time - 0.605128f) > 1e-4f) {
std::fprintf(stderr, "live assassin dualhand combo_01.msa two-window wrong "
"(windows=%zu)\n", dh.hit_windows.size());
return 1;
}
}
fmt::Msa pb;
if (fmt::parse_msa_file(base + "skill/palbang.msa", pb, &err)) {
// AttackingData 后还有 Group MotionEventData —— List 修复后二者都要解析到。
if (pb.hit_windows.size() != 1 || pb.hit_limit_count != 18 ||
std::fabs(pb.hit_windows[0].weapon_length - 130.0f) > 1e-3f ||
pb.events.size() != 3) {
std::fprintf(stderr, "live skill/palbang.msa wrong "
"(windows=%zu events=%zu)\n", pb.hit_windows.size(), pb.events.size());
return 1;
}
}
}
return 0;
}
+6 -3
View File
@@ -48,11 +48,14 @@ bool parse_body(const std::vector<std::string>& t, size_t& i, Node& node, std::s
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; }
// `Group Name { ... }` 和 `List Name { ... }`CTextFileLoader 的 GetTokenVector
// 都当子节点:Group 体是 key/子组,List 体是一串裸值(收进子节点的单行)。
if (ieq(tok, "Group") || ieq(tok, "List")) {
const char* kind = ieq(tok, "Group") ? "Group" : "List";
if (i + 2 >= t.size()) { if (err) *err = std::string(kind) + " 缺名字 / {"; return false; }
Node sub;
sub.name = t[i + 1];
if (t[i + 2] != "{") { if (err) *err = "Group '" + sub.name + "' 后缺 {"; return false; }
if (t[i + 2] != "{") { if (err) *err = std::string(kind) + " '" + sub.name + "' 后缺 {"; return false; }
i += 3;
if (!parse_body(t, i, sub, err)) return false;
node.groups.push_back(std::move(sub));