// W0 —— A1 地图格式解析单测。SHINSOO §9-W0 / §11.1。 // // 无参数:跑内联字符串单测(不需要资源)。 // 环境变量 M2_ASSETS 指向 Metin2 资产目录(默认 /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 #include #include #include 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(&h), sizeof(h)); b.append(reinterpret_cast(&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; }