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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013EJxkHiNKS4kybHS3XKyAJ
This commit is contained in:
shen
2026-08-31 20:02:12 +09:00
co-authored by Claude Sonnet 5
parent f4917a2b3b
commit 47baf6c0c6
414 changed files with 69568 additions and 385 deletions
+512
View File
@@ -0,0 +1,512 @@
// W0 —— A1 地图格式解析单测。SHINSOO §9-W0 / §11.1。
//
// 无参数:跑内联字符串单测(不需要资源)。
// 环境变量 M2_ASSETS 指向 Metin2 资产目录(默认 <repo>/assets):额外跑真实 A1 的 W0 验收断言。
#include "area_data.h"
#include "asset_resolver.h"
#include "environment.h"
#include "m2_coord.h"
#include "m2_tokvec.h"
#include "map_setting.h"
#include "property.h"
#include "splat.h"
#include "spt.h"
#include "terrain_files.h"
#include "terrain_mesh.h"
#include "texture_set.h"
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <string>
static int g_fail = 0;
#define CHECK(cond, msg) \
do { \
if (!(cond)) { \
std::fprintf(stderr, "FAIL: %s (%s:%d)\n", (msg), __FILE__, __LINE__); \
++g_fail; \
} \
} while (0)
static void test_tokvec() {
const std::string t = R"(
ScriptType MapSetting
MapSize 4 5
Start Object000
1.5 -2.0 3.0
569394331
End Object
# comment
Start Object000
9 9 9
End Object
)";
fmt::TokVecMap tv;
std::string err;
CHECK(fmt::parse_tokvec(t, tv, &err), "tokvec parse");
CHECK(tv.str("scripttype") == "MapSetting", "tokvec top-level str");
const auto* ms = tv.find("MapSize"); // 大小写不敏感
CHECK(ms && ms->size() == 2 && (*ms)[1] == "5", "tokvec MapSize tokens");
const auto* o0 = tv.find("object000");
CHECK(o0 && o0->size() == 4, "tokvec block flattens all lines");
CHECK(o0 && (*o0)[3] == "569394331", "tokvec block token order");
CHECK(tv.dup_count == 1, "tokvec dup detected");
}
static void test_coord() {
using namespace fmt::m2coord;
Vec3 g = position_to_godot(100.0, 200.0, 300.0);
CHECK(std::fabs(g.x - 1.0) < 1e-9, "coord x");
CHECK(std::fabs(g.y - 3.0) < 1e-9, "coord y = z*0.01");
CHECK(std::fabs(g.z + 2.0) < 1e-9, "coord z = -y*0.01");
CHECK(tile_id(1, 3) == 1003u, "tile_id");
CHECK(tile_dir(0, 0) == "000000", "tile_dir 0");
CHECK(tile_dir(1, 3) == "001003", "tile_dir 1,3");
int tx = -1, ty = -1;
CHECK(parse_tile_dir("003004", tx, ty) && tx == 3 && ty == 4, "parse_tile_dir");
CHECK(!parse_tile_dir("00300", tx, ty), "parse_tile_dir rejects len!=6");
CHECK(CHUNK_CM == 25600, "chunk cm");
// ypr_basis = 原始 D3DXMatrixRotationYawPitchRoll 端口,在 **Metin2 Z-up 空间**。
Mat3 y90 = ypr_basis(90.0, 0, 0); // yaw 绕 Metin2 Y(未转轴)
// m 是 row-major,基向量 = 列:new_x = (m[0],m[3],m[6])
CHECK(std::fabs(y90.m[0]) < 1e-6 && std::fabs(y90.m[6] + 1.0) < 1e-6,
"ypr yaw=90: local +X -> -Z (Metin2 space)");
Mat3 id = ypr_basis(0, 0, 0);
CHECK(std::fabs(id.m[0] - 1) < 1e-9 && std::fabs(id.m[4] - 1) < 1e-9 &&
std::fabs(id.m[8] - 1) < 1e-9,
"ypr zero = identity");
// object_basis_godot = ypr_basis 共轭到 Godot Y-up。放置物体必须用这个。
Mat3 og0 = object_basis_godot(0, 0, 0);
CHECK(std::fabs(og0.m[0] - 1) < 1e-9 && std::fabs(og0.m[4] - 1) < 1e-9 &&
std::fabs(og0.m[8] - 1) < 1e-9,
"object_basis_godot zero = identity");
// areadata 单值旋转 = roll = 朝向。共轭后必须是绕 Godot +Y 的 heading
// 不是绕 Godot Z 的翻滚(修复前的 bug)。
Mat3 rl90 = object_basis_godot(0, 0, 90.0);
CHECK(std::fabs(rl90.m[1]) < 1e-6 && std::fabs(rl90.m[4] - 1.0) < 1e-6 &&
std::fabs(rl90.m[7]) < 1e-6,
"object_basis_godot roll=90: Godot +Y axis fixed (heading, no roll)");
CHECK(std::fabs(rl90.m[0]) < 1e-6 && std::fabs(rl90.m[6] + 1.0) < 1e-6,
"object_basis_godot roll=90: local +X -> Godot -Z");
// 正交且右手(det=+1):取一个混合角
Mat3 m = object_basis_godot(20.0, -35.0, 110.0);
double det = m.m[0] * (m.m[4] * m.m[8] - m.m[5] * m.m[7]) -
m.m[1] * (m.m[3] * m.m[8] - m.m[5] * m.m[6]) +
m.m[2] * (m.m[3] * m.m[7] - m.m[4] * m.m[6]);
CHECK(std::fabs(det - 1.0) < 1e-6, "object_basis_godot det = +1");
double col0 = m.m[0] * m.m[0] + m.m[3] * m.m[3] + m.m[6] * m.m[6];
CHECK(std::fabs(col0 - 1.0) < 1e-6, "object_basis_godot col0 unit");
}
static void test_map_setting() {
const std::string t = R"(
ScriptType MapSetting
CellScale 200
HeightScale 0.500000
ViewRadius 128
MapSize 4 5
BasePosition 409600 896000
TextureSet textureset\metin2_A1.txt
Environment A1.msenv
)";
fmt::MapSetting s;
std::string err;
CHECK(fmt::parse_map_setting(t, s, &err), "map_setting parse");
CHECK(s.cell_scale == 200, "cell_scale");
CHECK(std::fabs(s.height_scale - 0.5) < 1e-9, "height_scale");
CHECK(s.map_size_x == 4 && s.map_size_y == 5, "map_size");
CHECK(s.chunk_count() == 20, "chunk_count");
CHECK(s.base_position_x == 409600 && s.base_position_y == 896000, "base_position");
CHECK(s.environment == "A1.msenv", "environment");
}
static void test_texture_set() {
const std::string t = R"(
TextureSet
TextureCount 2
Start Texture001
"d:\ymir work\terrainmaps\b\field\field 01.dds"
5.0 5.0 0.0 0.0 0 0 0
End Texture001
Start Texture002
"d:\ymir work\terrainmaps\b\field\field 02.dds"
6.0 6.0 0.0 0.0 1 10 20
End Texture002
)";
fmt::TextureSet ts;
std::string err;
CHECK(fmt::parse_texture_set(t, ts, &err), "texture_set parse");
CHECK(ts.declared_count == 2, "declared_count");
CHECK(ts.layers.size() == 2, "layers");
CHECK(ts.runtime_count() == 3, "runtime_count = layers + 1 (index0 empty)");
CHECK(ts.layers[1].splat_enabled && ts.layers[1].height_end == 20, "layer fields");
CHECK(ts.layers[0].texture.find("field 01.dds") != std::string::npos, "quoted path w/ space");
}
static void test_area_data() {
const std::string t = R"(
AreaDataFile
Start Object000
20801.746094 -13522.107422 17875.500000
569394331
0.000000#0.000000#180.000000
-40.000000
End Object
Start Object001
17069.0 -11315.7 17926.9
865570388
0.000000#15.000000#225.000000
End Object
ObjectCount 2
)";
fmt::AreaData a;
std::string err;
CHECK(fmt::parse_area_data(t, a, &err), "area_data parse");
CHECK(a.declared_count == 2, "declared");
CHECK(a.objects.size() == 2, "objects");
CHECK(a.objects[0].crc == 569394331u, "crc");
CHECK(std::fabs(a.objects[0].roll - 180.0f) < 1e-3f, "ypr roll");
CHECK(std::fabs(a.objects[0].height_bias + 40.0f) < 1e-3f, "height_bias");
CHECK(a.objects[0].x > 0 && a.objects[0].y < 0, "global cm: x>0 (east), y<0 (south)");
CHECK(std::fabs(a.objects[1].pitch - 15.0f) < 1e-3f && a.objects[1].height_bias == 0.0f,
"ypr without height_bias");
}
static void test_environment() {
const std::string t = R"(
ScriptType EnvrionmentData
ScriptVersion 1.0000
Group DirectionalLight
{
Direction 0.35 0.56 -0.75
Group Background
{
Enable 1
Diffuse 1.0 0.97 0.97 1.0
Ambient 0.0 0.0 0.0 1.0
}
Group Character
{
Enable 1
Ambient 0.15 0.15 0.15 1.0
}
}
Group Fog
{
foglevel 6
Color 0.69 0.74 0.83 1.0
}
Group SkyBox
{
Scale 3500.0 3500.0 3500.0
CloudTextureFileName "d:/ymir work/environment/clouds_zone01.tga"
List Gradient
{
0.09 0.28 0.65 0.0
0.23 0.40 0.72 0.0
0.37 0.49 0.72 0.0
}
}
)";
fmt::Environment e;
std::string err;
CHECK(fmt::parse_environment(t, e, &err), ("env parse: " + err).c_str());
CHECK(e.script_type == "EnvrionmentData", "env script_type (misspelled, accepted)");
CHECK(std::fabs(e.dir_light.direction[2] + 0.75f) < 1e-3f, "env dir light Direction z");
CHECK(e.dir_light.bg_enable && e.dir_light.ch_enable, "env bg/ch enable");
CHECK(std::fabs(e.dir_light.bg_diffuse[1] - 0.97f) < 1e-3f, "env bg diffuse");
CHECK(e.fog.enable && e.fog.fog_level == 6, "env fog (foglevel form)");
CHECK(std::fabs(e.sky.scale[0] - 3500.0f) < 1e-1f, "env skybox scale");
CHECK(e.sky.cloud_texture.find("clouds_zone01.tga") != std::string::npos, "env cloud tex path");
CHECK(e.sky.gradient.size() == 3, "env List Gradient rows");
CHECK(std::fabs(e.sky.gradient[2][0] - 0.37f) < 1e-3f, "env List row values");
}
static void test_property() {
const std::string prb = "YPRT\n274490822\nbuildingfile\t\"d:/ymir work/zone/x.gr2\"\n"
"propertyname\t\"rock_pillar_02\"\npropertytype\t\"Building\"\n"
"shadowflag\t\"1\"\n";
fmt::Property p;
std::string err;
CHECK(fmt::parse_property(prb, p, &err), ("property parse: " + err).c_str());
CHECK(p.crc == 274490822u, "property crc");
CHECK(p.type == fmt::PropertyType::Building, "property type Building");
CHECK(p.name == "rock_pillar_02", "property name");
CHECK(p.get("buildingfile").find("x.gr2") != std::string::npos, "property buildingfile");
CHECK(p.get("shadowflag") == "1", "property shadowflag");
fmt::Property bad;
CHECK(!fmt::parse_property("NOPE\n123\n", bad, &err), "property rejects non-YPRT");
}
static void test_terrain_mesh() {
// 合成一张 131x131 heightmap:平坦 + 一个已知斜坡
fmt::HeightMap hm;
hm.raw.assign(size_t(fmt::HEIGHTMAP_RAW_XY) * fmt::HEIGHTMAP_RAW_XY, 1000);
// 在 (sx,sy)=(10,10) 抬高
auto set = [&](int sx, int sy, uint16_t v) {
hm.raw[(sy + 1) * fmt::HEIGHTMAP_RAW_XY + (sx + 1)] = v;
};
set(10, 10, 2000);
fmt::TerrainMesh tm;
fmt::build_terrain_mesh(hm, 0, 0, 0.5, tm);
CHECK(tm.vertex_count() == 129 * 129, "terrain vert count");
CHECK((int)tm.positions.size() == 129 * 129 * 3, "terrain positions size");
CHECK((int)tm.indices.size() == 128 * 128 * 6, "terrain index count");
// 平坦区顶点高度 = 1000 * 0.5 * 0.01 = 5.0 m
size_t o = (size_t(0) * 129 + 0) * 3;
CHECK(std::fabs(tm.positions[o + 1] - 5.0f) < 1e-4f, "terrain flat height 5m");
// 平坦区法线朝上
CHECK(std::fabs(tm.normals[o + 1] - 1.0f) < 1e-4f, "terrain flat normal up");
// 抬高点:1000 高度差 * 0.5 * 0.01 = 5m 峰
size_t p = (size_t(10) * 129 + 10) * 3;
CHECK(std::fabs(tm.positions[p + 1] - 10.0f) < 1e-4f, "terrain bump height 10m");
// 峰邻域法线不再朝正上
size_t q = (size_t(10) * 129 + 9) * 3;
CHECK(tm.normals[q + 1] < 0.999f, "terrain slope normal tilted");
// 区块原点:tile(1,2) 的 (0,0) 顶点应在 (128*2, _, 256*2) m
fmt::TerrainMesh tm2;
fmt::build_terrain_mesh(hm, 1, 2, 0.5, tm2);
CHECK(std::fabs(tm2.positions[0] - 256.0f) < 1e-3f, "terrain chunk origin X = tile_x*256");
CHECK(std::fabs(tm2.positions[2] - 512.0f) < 1e-3f, "terrain chunk origin Z = tile_y*256");
// GetHeight 三角插值:格心
double h = fmt::terrain_height_at(hm, 100.0, 100.0, 0.5); // cell (0,0), xdist=ydist=100
CHECK(std::fabs(h - 500.0) < 1.0, "terrain_height_at flat = 500cm");
}
static void test_splat() {
// 3 层棋盘:texel 值 1 / 2 / 3
fmt::TileMap tile;
const int S = fmt::SPLAT_RAW_XY;
tile.raw.assign(size_t(S) * S, 1);
for (int y = 0; y < S; ++y)
for (int x = 0; x < S; ++x) {
if (x > S / 2) tile.raw[y * S + x] = 2;
if (x > S / 2 && y > S / 2) tile.raw[y * S + x] = 3;
}
fmt::SplatSet ss;
fmt::build_splat(tile, 4, ss);
CHECK(ss.layers.size() == 3, "splat: 3 used layers");
CHECK(ss.layers[0].layer == 1 && ss.layers[2].layer == 3, "splat: layers sorted by index");
// 图层 1 在左半区应满覆盖
CHECK(ss.layers[0].alpha[10 * S + 10] == 0xFF, "splat L1 covers its region");
CHECK(ss.layers[0].alpha[10 * S + (S - 10)] == 0x00, "splat L1 zero outside (no bleed there)");
// 图层 2 在右半区覆盖;边界 1px 会羽化进 L3 区
CHECK(ss.layers[1].alpha[10 * S + (S - 10)] == 0xFF, "splat L2 covers its region");
CHECK(ss.layers[2].coverage > 0, "splat L3 has coverage");
// 未出现的图层不生成
fmt::TileMap flat;
flat.raw.assign(size_t(S) * S, 1);
fmt::SplatSet ss2;
fmt::build_splat(flat, 8, ss2);
CHECK(ss2.layers.size() == 1, "splat: only used layers emitted");
}
static void test_asset_resolver_norm() {
using fmt::AssetResolver;
CHECK(AssetResolver::normalize("d:\\ymir work\\Tree\\A.spt") == "ymir work/tree/a.spt",
"resolver normalize backslash+drive+case");
CHECK(AssetResolver::normalize("D:/ymir work/env/./x/../y.tga") == "ymir work/env/y.tga",
"resolver normalize . and ..");
CHECK(AssetResolver::normalize("/foo//bar") == "foo/bar", "resolver normalize // and lead /");
}
static void test_spt_tokens() {
std::string b;
const uint32_t h = 0;
const uint32_t n = 13;
b.append(reinterpret_cast<const char*>(&h), sizeof(h));
b.append(reinterpret_cast<const char*>(&n), sizeof(n));
b.append("__IdvSpt_02_", n);
b.push_back('\0');
b += "\\\\PagodaTreeBark.tga";
b.push_back('\0');
b += "CompositeShadowMapB1.tgaQF";
b.push_back('\0');
b += "CompositeMapB1#N";
fmt::SptInfo si;
CHECK(fmt::sniff_spt(b, si) && si.ok, "spt inline magic");
CHECK(si.composite_texture == "CompositeMapB1.dds", "spt composite atlas token");
CHECK(si.self_shadow_texture == "CompositeShadowMapB1.dds", "spt shadow atlas token");
}
// ---- live A1 断言(M2_ASSETS 设了才跑)----
static void test_live(const std::string& assets) {
const std::string map = assets + "/OutdoorA1/metin2_map_a1";
std::string err;
fmt::MapSetting s;
CHECK(fmt::parse_map_setting_file(map + "/setting.txt", s, &err),
("live setting.txt: " + err).c_str());
CHECK(s.map_size_x == 4 && s.map_size_y == 5, "live MapSize 4x5");
CHECK(s.chunk_count() == 20, "live 20 chunks");
// 20 个区块目录都在
int found = 0;
for (int tx = 0; tx < s.map_size_x; ++tx)
for (int ty = 0; ty < s.map_size_y; ++ty) {
fmt::HeightMap hm;
std::string e2;
if (fmt::load_height_map(map + "/" + fmt::m2coord::tile_dir(tx, ty) + "/height.raw",
hm, &e2))
++found;
else
std::fprintf(stderr, " tile %s: %s\n",
fmt::m2coord::tile_dir(tx, ty).c_str(), e2.c_str());
}
CHECK(found == 20, "live: all 20 height.raw load as 131x131");
fmt::TileMap tm;
CHECK(fmt::load_tile_map(map + "/000000/tile.raw", tm, &err),
("live tile.raw 258x258: " + err).c_str());
fmt::AttrMap am;
CHECK(fmt::load_attr_map(map + "/000000/attr.atr", am, &err),
("live attr.atr magic 2634: " + err).c_str());
fmt::WaterMap wm;
CHECK(fmt::load_water_map(map + "/000000/water.wtr", wm, &err),
("live water.wtr magic 5426: " + err).c_str());
// TextureSet:解析路径 textureset\metin2_A1.txt(大小写不敏感 -> 实盘小写)
fmt::TextureSet ts;
bool got_ts = fmt::parse_texture_set_file(
assets + "/textureset/textureset/metin2_a1.txt", ts, &err);
CHECK(got_ts, ("live textureset: " + err).c_str());
if (got_ts) CHECK(ts.layers.size() == 17, "live TextureSet 17 layers");
fmt::AreaData ad;
CHECK(fmt::parse_area_data_file(map + "/000000/areadata.txt", ad, &err),
("live areadata: " + err).c_str());
CHECK(ad.declared_count == 23, "live areadata ObjectCount 23");
CHECK(ad.objects.size() == 23, "live areadata parsed 23");
if (!ad.objects.empty())
CHECK(ad.objects[0].crc == 569394331u, "live areadata Object000 CRC 569394331");
// 坐标系:000000 在原点附近,x,y 幅度 < 一个区块(全局 == 本地在此区块巧合)
bool o000_ok = true;
for (auto& o : ad.objects)
if (o.x < 0 || o.x >= fmt::m2coord::CHUNK_CM || o.y > 0 || o.y <= -fmt::m2coord::CHUNK_CM)
o000_ok = false;
CHECK(o000_ok, "live 000000 areadata within chunk 0 (global cm, x>=0 y<=0)");
// 别的区块要落在该区块的全局范围 —— 001003 的 x 应 > 25600
fmt::AreaData ad13;
if (fmt::parse_area_data_file(map + "/001003/areadata.txt", ad13, &err) && !ad13.objects.empty()) {
bool shifted = false;
for (auto& o : ad13.objects)
if (o.x >= fmt::m2coord::CHUNK_CM) shifted = true;
CHECK(shifted, "live: 001003 objects are GLOBAL cm (x > 25600), not chunk-local");
}
// PARITY §3.7 —— 相邻区块共享边高度 / 顶点位置一致(无接缝)
{
fmt::HeightMap h00, h10;
std::string e3;
bool ok00 = fmt::load_height_map(map + "/000000/height.raw", h00, &e3);
bool ok10 = fmt::load_height_map(map + "/001000/height.raw", h10, &e3);
CHECK(ok00 && ok10, "seam: load 000000 + 001000 height.raw");
if (ok00 && ok10) {
int bad = 0;
for (int j = 0; j < fmt::TerrainMesh::VERTS_XY; ++j)
if (h00.at(128, j) != h10.at(0, j)) // 000000 右缘 vs 001000 左缘
++bad;
CHECK(bad == 0, "seam: 000000 col-128 heights == 001000 col-0 (border samples)");
fmt::TerrainMesh m00, m10;
fmt::build_terrain_mesh(h00, 0, 0, s.height_scale, m00);
fmt::build_terrain_mesh(h10, 1, 0, s.height_scale, m10);
const int N = fmt::TerrainMesh::VERTS_XY;
int pbad = 0;
for (int j = 0; j < N; ++j) {
size_t a = (size_t(j) * N + (N - 1)) * 3; // m00 右缘
size_t b = (size_t(j) * N + 0) * 3; // m10 左缘
for (int k = 0; k < 3; ++k)
if (std::fabs(m00.positions[a + k] - m10.positions[b + k]) > 1e-3f)
++pbad;
}
CHECK(pbad == 0, "seam: 000000/001000 shared-edge vertex positions match");
}
}
// .spt 嗅探(几何不可读,仅确认 magic + 抽贴图名 —— W4 结论 c 的程序化确认)
{
fmt::SptInfo si;
bool got = fmt::sniff_spt_file(
assets + "/Tree/ymir work/tree/b1_pagodatree_rt.spt", si);
CHECK(got && si.ok, "live .spt magic __IdvSpt");
CHECK(si.magic.rfind("__IdvSpt", 0) == 0, "live .spt magic string");
CHECK(!si.texture_refs.empty(), "live .spt has embedded texture refs");
CHECK(si.composite_texture == "CompositeMapB1.dds", "live .spt composite atlas");
CHECK(si.self_shadow_texture == "CompositeShadowMapB1.dds", "live .spt shadow atlas");
}
// EnvironmentA1.msenv 由虚拟路径解析
fmt::Environment env;
CHECK(fmt::parse_environment_file(assets + "/ETC/ymir work/environment/a1.msenv", env, &err),
("live a1.msenv: " + err).c_str());
CHECK(env.script_type == "EnvrionmentData", "live msenv script_type");
CHECK(env.fog.enable && env.sky.gradient.size() >= 8, "live msenv fog + gradient list");
// Property CRC 注册表
fmt::PropertyRegistry reg;
CHECK(reg.scan(assets + "/Property", &err), ("live Property scan: " + err).c_str());
CHECK(reg.by_crc.size() > 1000, "live Property registry > 1000 entries");
const fmt::Property* p0 = reg.find(569394331u);
CHECK(p0 != nullptr, "live Property CRC 569394331 resolves");
if (p0) CHECK(p0->type == fmt::PropertyType::Tree, "live CRC 569394331 = Tree (Pagoda1)");
// areadata 的每个 CRC 都能在注册表命中
int miss = 0;
for (auto& o : ad.objects)
if (!reg.find(o.crc)) ++miss;
CHECK(miss == 0, "live: every 000000 areadata CRC found in Property registry");
// AssetResolver
fmt::AssetResolver res;
CHECK(res.build(assets, fmt::AssetResolver::default_priority(), &err),
("live AssetResolver build: " + err).c_str());
CHECK(res.files_indexed > 10000, "live resolver indexed > 10000 files");
std::string r;
CHECK(!res.resolve("d:/ymir work/environment/clouds_zone01.tga", &r).empty(),
("live resolve cloud tga: " + r).c_str());
CHECK(!res.resolve("d:\\ymir work\\tree\\b1_pagodatree_rt.spt", &r).empty(),
("live resolve pagoda spt: " + r).c_str());
CHECK(res.resolve("d:/ymir work/does/not/exist.xxx", &r).empty(),
"live resolve missing -> empty");
// TextureSet 里的图层贴图路径应可解析
int tex_miss = 0;
for (auto& L : ts.layers)
if (res.resolve(L.texture, nullptr).empty()) ++tex_miss;
CHECK(tex_miss == 0, "live: all TextureSet layer textures resolve");
}
int main() {
test_tokvec();
test_coord();
test_map_setting();
test_texture_set();
test_area_data();
test_environment();
test_property();
test_terrain_mesh();
test_asset_resolver_norm();
test_spt_tokens();
if (const char* a = std::getenv("M2_ASSETS"); a && *a) {
std::fprintf(stderr, "-- live A1 asserts (M2_ASSETS=%s) --\n", a);
test_live(a);
} else {
std::fprintf(stderr, "-- M2_ASSETS unset: skipping live A1 asserts --\n");
}
if (g_fail) {
std::fprintf(stderr, "%d check(s) failed\n", g_fail);
return 1;
}
std::fprintf(stderr, "all checks passed\n");
return 0;
}