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,112 @@
|
||||
// mtnet::SecureCipher round trip — exercises the full Metin2 KX handshake +
|
||||
// stream cipher + AEAD session-token path offline (no server needed).
|
||||
#include "../src/net/secure_cipher.h"
|
||||
#include "../src/net/wire.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
using mtnet::SecureCipher;
|
||||
|
||||
static int g_fail = 0;
|
||||
#define CHECK(c, msg) \
|
||||
do { \
|
||||
if (!(c)) { \
|
||||
std::fprintf(stderr, "FAIL: %s\n", msg); \
|
||||
++g_fail; \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
int main() {
|
||||
CHECK(SecureCipher::ensure_sodium_init(), "sodium_init");
|
||||
|
||||
// --- key exchange: server sends KEY_CHALLENGE, client answers KEY_RESPONSE ---
|
||||
SecureCipher server, client;
|
||||
CHECK(server.initialize(), "server initialize");
|
||||
CHECK(client.initialize(), "client initialize");
|
||||
|
||||
uint8_t server_pk[SecureCipher::PK_SIZE];
|
||||
server.get_public_key(server_pk);
|
||||
|
||||
uint8_t challenge[SecureCipher::CHALLENGE_SIZE];
|
||||
randombytes_buf(challenge, sizeof(challenge));
|
||||
|
||||
CHECK(client.compute_client_keys(server_pk), "client compute keys");
|
||||
|
||||
uint8_t client_pk[SecureCipher::PK_SIZE];
|
||||
client.get_public_key(client_pk);
|
||||
CHECK(server.compute_server_keys(client_pk), "server compute keys");
|
||||
|
||||
uint8_t response[crypto_auth_BYTES];
|
||||
client.compute_challenge_response(challenge, response);
|
||||
CHECK(server.verify_challenge_response(challenge, response), "server verifies challenge response");
|
||||
|
||||
// tamper -> must fail
|
||||
uint8_t bad = response[0] ^ 0xFF;
|
||||
uint8_t bad_resp[crypto_auth_BYTES];
|
||||
std::memcpy(bad_resp, response, sizeof(response));
|
||||
bad_resp[0] = bad;
|
||||
CHECK(!server.verify_challenge_response(challenge, bad_resp), "tampered response rejected");
|
||||
|
||||
// --- KEY_COMPLETE: server encrypts a session token, client decrypts it ---
|
||||
uint8_t token[SecureCipher::SESSION_TOKEN_SIZE];
|
||||
randombytes_buf(token, sizeof(token));
|
||||
uint8_t enc[SecureCipher::SESSION_TOKEN_SIZE + SecureCipher::TAG_SIZE];
|
||||
uint8_t nonce[SecureCipher::NONCE_SIZE];
|
||||
CHECK(server.encrypt_token(token, sizeof(token), enc, nonce), "server encrypt token");
|
||||
|
||||
uint8_t dec[SecureCipher::SESSION_TOKEN_SIZE];
|
||||
CHECK(client.decrypt_token(enc, sizeof(enc), nonce, dec), "client decrypt token");
|
||||
CHECK(std::memcmp(token, dec, sizeof(token)) == 0, "session token round trips");
|
||||
|
||||
server.set_activated(true);
|
||||
client.set_activated(true);
|
||||
|
||||
// --- stream cipher: C->S traffic in arbitrary chunks, order-sensitive ---
|
||||
const std::vector<size_t> chunk_sizes = {1, 4, 60, 3, 5, 100, 7, 64, 200, 13};
|
||||
std::string acc_plain, acc_recovered;
|
||||
for (size_t n : chunk_sizes) {
|
||||
std::vector<uint8_t> buf(n);
|
||||
for (size_t i = 0; i < n; ++i) {
|
||||
buf[i] = static_cast<uint8_t>((acc_plain.size() + i) * 7 + 1);
|
||||
}
|
||||
acc_plain.append(reinterpret_cast<char *>(buf.data()), n);
|
||||
client.encrypt_in_place(buf.data(), n); // C->S encrypt
|
||||
// on the wire it looks like ciphertext; server decrypts the same bytes
|
||||
server.decrypt_in_place(buf.data(), n);
|
||||
acc_recovered.append(reinterpret_cast<char *>(buf.data()), n);
|
||||
}
|
||||
CHECK(acc_plain == acc_recovered, "C->S stream recovers across chunk boundaries");
|
||||
CHECK(client.tx_nonce() == acc_plain.size(), "client tx byte counter advanced");
|
||||
CHECK(server.rx_nonce() == acc_plain.size(), "server rx byte counter advanced");
|
||||
|
||||
// --- S->C direction is independent ---
|
||||
{
|
||||
std::vector<uint8_t> buf(150);
|
||||
for (size_t i = 0; i < buf.size(); ++i) {
|
||||
buf[i] = static_cast<uint8_t>(i ^ 0x5A);
|
||||
}
|
||||
std::vector<uint8_t> orig = buf;
|
||||
server.encrypt_in_place(buf.data(), buf.size()); // S->C
|
||||
CHECK(buf != orig, "S->C ciphertext differs from plaintext");
|
||||
client.decrypt_in_place(buf.data(), buf.size());
|
||||
CHECK(buf == orig, "S->C stream round trips");
|
||||
}
|
||||
|
||||
// --- wire.h struct sizes (must match the fork's #pragma pack(1) layout) ---
|
||||
CHECK(sizeof(mtnet::DynHeader) == 4, "DynHeader is 4 bytes");
|
||||
CHECK(sizeof(mtnet::GCKeyChallenge) == 4 + 32 + 32 + 4, "GCKeyChallenge 72 bytes");
|
||||
CHECK(sizeof(mtnet::CGKeyResponse) == 4 + 32 + 32, "CGKeyResponse 68 bytes");
|
||||
CHECK(sizeof(mtnet::GCKeyComplete) == 4 + 48 + 24, "GCKeyComplete 76 bytes");
|
||||
CHECK(sizeof(mtnet::CGLogin3) == 4 + 31 + 17, "CGLogin3 52 bytes");
|
||||
CHECK(sizeof(mtnet::GCAuthSuccess) == 4 + 4 + 1, "GCAuthSuccess 9 bytes");
|
||||
|
||||
if (g_fail) {
|
||||
std::fprintf(stderr, "%d check(s) failed\n", g_fail);
|
||||
return 1;
|
||||
}
|
||||
std::printf("all checks passed\n");
|
||||
return 0;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,140 @@
|
||||
// MarkImageSet + the GC_MARK_* body parsers — synthetic packets in, guild-mark
|
||||
// pixels out. No socket: this covers the parse/decompress/blit path that the
|
||||
// MarkClient (mark_client.h) drives over the wire.
|
||||
#include "../src/net/mark_image.h"
|
||||
#include "../src/net/wire.h"
|
||||
|
||||
#include <lzo/lzo1x.h>
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
|
||||
using namespace mtnet;
|
||||
|
||||
static int g_fail = 0;
|
||||
#define CHECK(c, msg) \
|
||||
do { \
|
||||
if (!(c)) { \
|
||||
std::fprintf(stderr, "FAIL: %s\n", msg); \
|
||||
++g_fail; \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
static void put_u16(std::vector<uint8_t> &b, uint16_t v) {
|
||||
b.push_back((uint8_t)(v & 0xFF));
|
||||
b.push_back((uint8_t)(v >> 8));
|
||||
}
|
||||
static void put_u32(std::vector<uint8_t> &b, uint32_t v) {
|
||||
for (int i = 0; i < 4; ++i) {
|
||||
b.push_back((uint8_t)((v >> (8 * i)) & 0xFF));
|
||||
}
|
||||
}
|
||||
|
||||
// LZO1X-compress one 64x48 RGBA block (MARK_BLOCK_PIXELS words).
|
||||
static std::vector<uint8_t> compress_block(const uint32_t *px) {
|
||||
lzo_init();
|
||||
static std::vector<uint8_t> wrk(LZO1X_1_MEM_COMPRESS);
|
||||
std::vector<uint8_t> out(MARK_BLOCK_PIXELS * 4 + MARK_BLOCK_PIXELS + 64);
|
||||
lzo_uint out_len = out.size();
|
||||
int r = lzo1x_1_compress((const uint8_t *)px, MARK_BLOCK_PIXELS * 4, out.data(), &out_len,
|
||||
wrk.data());
|
||||
if (r != LZO_E_OK) {
|
||||
std::fprintf(stderr, "FAIL: lzo1x_1_compress -> %d\n", r);
|
||||
++g_fail;
|
||||
}
|
||||
out.resize(out_len);
|
||||
return out;
|
||||
}
|
||||
|
||||
int main() {
|
||||
// Guild 4242's mark lives at mark_id 1281 -> image 1, position 1 (row 0,
|
||||
// col 1): pixel origin (16, 0). Guild 7 -> mark_id 3 -> image 0, pos 3.
|
||||
const uint32_t kGuildA = 4242, kMarkA = MARK_PER_IMAGE + 1; // 1281
|
||||
const uint32_t kGuildB = 7, kMarkB = 3;
|
||||
|
||||
// --- GC_MARK_IDXLIST body: count x {u16 guild_id, u16 mark_id} ---
|
||||
MarkImageSet set;
|
||||
{
|
||||
std::vector<uint8_t> body;
|
||||
put_u16(body, (uint16_t)kGuildA);
|
||||
put_u16(body, (uint16_t)kMarkA);
|
||||
put_u16(body, (uint16_t)kGuildB);
|
||||
put_u16(body, (uint16_t)kMarkB);
|
||||
size_t n = parse_mark_idxlist(body.data(), body.size(), 2, set);
|
||||
CHECK(n == 2, "idxlist: 2 entries parsed");
|
||||
}
|
||||
CHECK(set.has_mark(kGuildA) && set.has_mark(kGuildB), "idxlist: both guilds registered");
|
||||
CHECK(set.mark_id(kGuildA) == kMarkA, "idxlist: mark id stored");
|
||||
CHECK(!set.has_mark(999), "idxlist: unknown guild absent");
|
||||
|
||||
// needed images = {0, 1} ascending
|
||||
std::vector<int> need = set.needed_images();
|
||||
CHECK(need.size() == 2 && need[0] == 0 && need[1] == 1, "needed_images = {0,1}");
|
||||
|
||||
// rect: guild A at image 1, (16, 0); guild B at image 0, (48, 0)
|
||||
MarkRect ra = set.rect_of(kGuildA);
|
||||
CHECK(ra.found && ra.img_idx == 1 && ra.x == 16 && ra.y == 0 && ra.w == 16 && ra.h == 12,
|
||||
"rect_of(A) = img1 (16,0) 16x12");
|
||||
MarkRect rb = set.rect_of(kGuildB);
|
||||
CHECK(rb.found && rb.img_idx == 0 && rb.x == 48 && rb.y == 0, "rect_of(B) = img0 (48,0)");
|
||||
|
||||
// --- GC_MARK_BLOCK body for image 1, block 0 (covers marks at cols 0..3) ---
|
||||
// Paint guild A's 16x12 cell (origin 16,0 within the block) a solid colour.
|
||||
const uint32_t kColour = 0xFF3399CCu; // AABBGGRR
|
||||
{
|
||||
std::vector<uint32_t> block(MARK_BLOCK_PIXELS, 0);
|
||||
for (int j = 0; j < GUILD_MARK_HEIGHT; ++j) {
|
||||
for (int i = 0; i < GUILD_MARK_WIDTH; ++i) {
|
||||
block[(size_t)j * MARK_BLOCK_WIDTH + (16 + i)] = kColour;
|
||||
}
|
||||
}
|
||||
std::vector<uint8_t> comp = compress_block(block.data());
|
||||
|
||||
std::vector<uint8_t> body;
|
||||
body.push_back(0); // block_pos 0
|
||||
put_u32(body, (uint32_t)comp.size());
|
||||
body.insert(body.end(), comp.begin(), comp.end());
|
||||
|
||||
size_t applied = parse_mark_block(body.data(), body.size(), /*img_idx=*/1, /*count=*/1, set);
|
||||
CHECK(applied == 1, "block: 1 block applied");
|
||||
}
|
||||
|
||||
// guild A's mark pixels should now be the solid colour; guild B (no block
|
||||
// for image 0 yet) should come back empty.
|
||||
std::vector<uint32_t> pa = set.mark_pixels(kGuildA);
|
||||
CHECK(pa.size() == (size_t)(GUILD_MARK_WIDTH * GUILD_MARK_HEIGHT), "mark_pixels(A): 192 words");
|
||||
bool all_colour = !pa.empty();
|
||||
for (uint32_t p : pa) {
|
||||
all_colour = all_colour && (p == kColour);
|
||||
}
|
||||
CHECK(all_colour, "mark_pixels(A): every pixel is the painted colour");
|
||||
|
||||
CHECK(set.mark_pixels(kGuildB).empty(), "mark_pixels(B): empty (image 0 not downloaded)");
|
||||
|
||||
// corrupt compressed data -> apply_block fails, image untouched
|
||||
{
|
||||
uint8_t junk[8] = {1, 2, 3, 4, 5, 6, 7, 8};
|
||||
CHECK(!set.apply_block(1, 1, junk, sizeof(junk)), "apply_block: junk rejected");
|
||||
}
|
||||
// out-of-range indices
|
||||
CHECK(!set.apply_block(-1, 0, (const uint8_t *)"x", 1), "apply_block: bad img idx");
|
||||
CHECK(!set.apply_block(0, MARK_BLOCK_TOTAL_COUNT, (const uint8_t *)"x", 1),
|
||||
"apply_block: bad block pos");
|
||||
|
||||
// truncated block body -> nothing applied, no crash
|
||||
{
|
||||
std::vector<uint8_t> body;
|
||||
body.push_back(2);
|
||||
put_u32(body, 9999); // claims 9999 bytes that aren't there
|
||||
size_t applied = parse_mark_block(body.data(), body.size(), 0, 1, set);
|
||||
CHECK(applied == 0, "block: truncated body -> 0 applied");
|
||||
}
|
||||
|
||||
if (g_fail) {
|
||||
std::fprintf(stderr, "%d check(s) failed\n", g_fail);
|
||||
return 1;
|
||||
}
|
||||
std::printf("PASS: net_mark_test\n");
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
// EterPack writer -> reader round trip (no external pack file needed).
|
||||
#include "../src/pack/eterpack.h"
|
||||
#include "../src/pack/pack_mount.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
using namespace mtpack;
|
||||
|
||||
static int g_fail = 0;
|
||||
#define CHECK(c, msg) \
|
||||
do { \
|
||||
if (!(c)) { \
|
||||
std::fprintf(stderr, "FAIL: %s\n", msg); \
|
||||
++g_fail; \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
static std::vector<uint8_t> bytes(const std::string &s) {
|
||||
return std::vector<uint8_t>(s.begin(), s.end());
|
||||
}
|
||||
|
||||
static bool roundtrip(bool encrypt, const char *tag) {
|
||||
std::vector<InputFile> in;
|
||||
in.push_back({"d:/ymir work/pc/warrior/warrior.gr2", bytes(std::string(5000, 'A'))}); // compressible
|
||||
{
|
||||
std::vector<uint8_t> rnd(4096);
|
||||
for (size_t i = 0; i < rnd.size(); ++i) {
|
||||
rnd[i] = static_cast<uint8_t>((i * 2654435761u) >> 13);
|
||||
}
|
||||
in.push_back({"textureset/metin2_a1.txt", rnd});
|
||||
}
|
||||
in.push_back({"tiny.dat", bytes("x")});
|
||||
in.push_back({"nested/deep/path/file.bin", bytes("hello \x00 world binary\xff\xfe")});
|
||||
|
||||
std::string path = std::string(std::getenv("TMPDIR") ? std::getenv("TMPDIR") : "/tmp") +
|
||||
"/mtpack_test_" + tag + ".epk";
|
||||
|
||||
std::string err;
|
||||
if (!write_pack(path, in, encrypt, &err)) {
|
||||
std::fprintf(stderr, "write_pack(%s): %s\n", tag, err.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
EterPack pk;
|
||||
if (!pk.open(path, &err)) {
|
||||
std::fprintf(stderr, "open(%s): %s\n", tag, err.c_str());
|
||||
return false;
|
||||
}
|
||||
CHECK(pk.count() == in.size(), "entry count");
|
||||
CHECK(pk.name_field() == PACK_NAME_FIELD_DEFAULT, "derived name field == default");
|
||||
|
||||
for (const auto &f : in) {
|
||||
CHECK(pk.has(f.name), (std::string("has ") + f.name).c_str());
|
||||
// case / slash insensitivity
|
||||
std::string up = f.name;
|
||||
for (auto &c : up) {
|
||||
c = static_cast<char>(std::toupper((unsigned char)c));
|
||||
}
|
||||
std::replace(up.begin(), up.end(), '/', '\\');
|
||||
CHECK(pk.has(up), (std::string("has (norm) ") + f.name).c_str());
|
||||
|
||||
std::vector<uint8_t> got;
|
||||
if (!pk.read(f.name, got, &err)) {
|
||||
std::fprintf(stderr, "read(%s): %s\n", f.name.c_str(), err.c_str());
|
||||
++g_fail;
|
||||
continue;
|
||||
}
|
||||
CHECK(got == f.data, (std::string("bytes match ") + f.name).c_str());
|
||||
}
|
||||
|
||||
CHECK(!pk.read("does/not/exist", *(new std::vector<uint8_t>()), &err), "missing file -> false");
|
||||
std::remove(path.c_str());
|
||||
return true;
|
||||
}
|
||||
|
||||
static std::string tmp(const char *n) {
|
||||
return std::string(std::getenv("TMPDIR") ? std::getenv("TMPDIR") : "/tmp") + "/mtpack_" + n;
|
||||
}
|
||||
|
||||
static bool mount_test() {
|
||||
// pack A: base assets, "ymir work/" layout
|
||||
std::vector<InputFile> a;
|
||||
a.push_back({"ymir work/ui/pattern/board_base.tga", bytes("BASE-BOARD-v1")});
|
||||
a.push_back({"ymir work/tree/b1_pagoda.spt", bytes(std::string(2000, 'S'))});
|
||||
a.push_back({"locale/loading.png", bytes("PNGDATA")});
|
||||
std::string pa = tmp("mount_a.epk");
|
||||
std::string err;
|
||||
if (!write_pack(pa, a, false, &err)) {
|
||||
std::fprintf(stderr, "write A: %s\n", err.c_str());
|
||||
return false;
|
||||
}
|
||||
// pack B: a patch that overrides board_base
|
||||
std::vector<InputFile> b;
|
||||
b.push_back({"ymir work/ui/pattern/board_base.tga", bytes("BASE-BOARD-v2-PATCHED")});
|
||||
std::string pb = tmp("metin2_patch_x.epk");
|
||||
if (!write_pack(pb, b, true, &err)) {
|
||||
std::fprintf(stderr, "write B: %s\n", err.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
mtpack::PackMount m;
|
||||
CHECK(m.mount(pa, &err), ("mount A: " + err).c_str());
|
||||
CHECK(m.mount(pb, &err), ("mount B: " + err).c_str());
|
||||
CHECK(m.pack_count() == 2, "2 packs mounted");
|
||||
|
||||
std::vector<uint8_t> out;
|
||||
// full path
|
||||
CHECK(m.has("ymir work/tree/b1_pagoda.spt"), "has by full path");
|
||||
CHECK(m.read("ymir work/tree/b1_pagoda.spt", out) && out.size() == 2000, "read by full path");
|
||||
// virtual path with drive + backslashes + case
|
||||
CHECK(m.has("D:\\YMIR WORK\\Tree\\B1_Pagoda.spt"), "has by d:\\ virtual path");
|
||||
CHECK(m.read("d:/ymir work/tree/b1_pagoda.spt", out) && out.size() == 2000, "read by virtual");
|
||||
// "ymir work/"-suffix keying: a vpath that omits the leading dirs
|
||||
CHECK(m.read("somewhere/ymir work/ui/pattern/board_base.tga", out), "read via ymir-suffix");
|
||||
// later pack (patch) wins
|
||||
CHECK(std::string(out.begin(), out.end()) == "BASE-BOARD-v2-PATCHED", "patch pack overrides base");
|
||||
// non-ymir path still works
|
||||
CHECK(m.read("locale/loading.png", out) &&
|
||||
std::string(out.begin(), out.end()) == "PNGDATA",
|
||||
"non-ymir path");
|
||||
CHECK(!m.has("does/not/exist"), "missing -> false");
|
||||
|
||||
std::remove(pa.c_str());
|
||||
std::remove(pb.c_str());
|
||||
return true;
|
||||
}
|
||||
|
||||
int main() {
|
||||
CHECK(roundtrip(false, "plain"), "plain roundtrip ran");
|
||||
CHECK(roundtrip(true, "enc"), "encrypted roundtrip ran");
|
||||
CHECK(mount_test(), "PackMount test ran");
|
||||
|
||||
if (g_fail) {
|
||||
std::fprintf(stderr, "%d check(s) failed\n", g_fail);
|
||||
return 1;
|
||||
}
|
||||
std::printf("all checks passed\n");
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
// item_proto / mob_proto reader — against the real locale files if present.
|
||||
// Set M2_ASSETS to the Metin2 assets dir, else falls back to a repo-relative
|
||||
// guess; skips (passes) if the files are not found.
|
||||
#include "../src/proto/proto.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <string>
|
||||
|
||||
using namespace mtproto;
|
||||
|
||||
static int g_fail = 0;
|
||||
#define CHECK(c, msg) \
|
||||
do { \
|
||||
if (!(c)) { \
|
||||
std::fprintf(stderr, "FAIL: %s\n", msg); \
|
||||
++g_fail; \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
static std::string assets_root() {
|
||||
if (const char *e = std::getenv("M2_ASSETS")) {
|
||||
return e;
|
||||
}
|
||||
return "../../assets"; // ctest cwd = build/extension -> <repo>/assets
|
||||
}
|
||||
|
||||
static bool exists(const std::string &p) {
|
||||
FILE *f = std::fopen(p.c_str(), "rb");
|
||||
if (f) {
|
||||
std::fclose(f);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
int main() {
|
||||
const std::string base = assets_root() + "/locale/locale/en";
|
||||
const std::string ipath = base + "/item_proto";
|
||||
const std::string mpath = base + "/mob_proto";
|
||||
|
||||
if (!exists(ipath) || !exists(mpath)) {
|
||||
std::printf("skipped (no locale proto files at %s)\n", base.c_str());
|
||||
return 0;
|
||||
}
|
||||
|
||||
// --- item_proto ---
|
||||
{
|
||||
Proto p;
|
||||
std::string err;
|
||||
bool ok = load_proto(ipath, ITEM_PROTO_KEY, p, &err);
|
||||
CHECK(ok, ("load item_proto: " + err).c_str());
|
||||
if (ok) {
|
||||
CHECK(p.fourcc == 0x5850494Du, "item fourcc MIPX");
|
||||
CHECK(p.version == 1, "item version 1");
|
||||
CHECK(p.stride == 236, "item stride 236 (== pack(1) sizeof TItemTable)");
|
||||
CHECK(p.elements > 100, "item elements > 100");
|
||||
CHECK(p.blob.size() == static_cast<size_t>(p.stride) * p.elements, "item blob size");
|
||||
|
||||
// every record: vnum monotonically increasing, name printable ascii
|
||||
uint32_t prev = 0;
|
||||
int named = 0, mono = 1;
|
||||
uint8_t max_spec = 0;
|
||||
for (uint32_t i = 0; i < p.elements; ++i) {
|
||||
ItemRecord it = parse_item(p.record(i), p.stride);
|
||||
if (it.vnum && it.vnum <= prev) {
|
||||
mono = 0;
|
||||
}
|
||||
prev = it.vnum ? it.vnum : prev;
|
||||
if (!it.name.empty()) {
|
||||
++named;
|
||||
}
|
||||
if (it.specular > max_spec) {
|
||||
max_spec = it.specular;
|
||||
}
|
||||
}
|
||||
CHECK(mono, "item vnums non-decreasing");
|
||||
CHECK(named > p.elements * 0.8, "item: >80% have a name");
|
||||
std::printf("item_proto: %u items, stride %u, %d named, max bSpecular=%u\n",
|
||||
p.elements, p.stride, named, max_spec);
|
||||
|
||||
ItemRecord first = parse_item(p.record(0), p.stride);
|
||||
std::printf(" [0] vnum=%u '%s' type=%u sub=%u weight=%u\n", first.vnum,
|
||||
first.name.c_str(), first.type, first.sub_type, first.weight);
|
||||
CHECK(first.vnum > 0 && first.vnum < 100000, "item[0] vnum sane");
|
||||
CHECK(!first.name.empty(), "item[0] has a name");
|
||||
|
||||
// alValues[3] = body-armor shape index (anchored by bSpecular @234).
|
||||
// "Monk Plate Armour" 11200..11209 all share shape 3; specular ramps
|
||||
// with the refine level (0 -> 100).
|
||||
bool found_armor = false;
|
||||
for (uint32_t i = 0; i < p.elements; ++i) {
|
||||
ItemRecord it = parse_item(p.record(i), p.stride);
|
||||
if (it.vnum == 11209) {
|
||||
found_armor = true;
|
||||
std::printf(" 11209 values=[%d,%d,%d,%d,%d,%d] spec=%u\n", it.values[0],
|
||||
it.values[1], it.values[2], it.values[3], it.values[4], it.values[5],
|
||||
it.specular);
|
||||
CHECK(it.values[3] == 3, "item 11209: values[3] == shape 3");
|
||||
CHECK(it.specular == 100, "item 11209 (+9): bSpecular == 100");
|
||||
}
|
||||
if (it.vnum == 11200) {
|
||||
CHECK(it.values[3] == 3 && it.specular == 0,
|
||||
"item 11200 (+0): shape 3, specular 0");
|
||||
}
|
||||
}
|
||||
CHECK(found_armor, "item_proto contains vnum 11209");
|
||||
}
|
||||
}
|
||||
|
||||
// --- mob_proto ---
|
||||
{
|
||||
Proto p;
|
||||
std::string err;
|
||||
bool ok = load_proto(mpath, MOB_PROTO_KEY, p, &err);
|
||||
CHECK(ok, ("load mob_proto: " + err).c_str());
|
||||
if (ok) {
|
||||
CHECK(p.fourcc == 0x54504D4Du, "mob fourcc MMPT");
|
||||
CHECK(p.stride == 335, "mob stride 335 (derived from realSize/elements)");
|
||||
CHECK(p.elements > 100, "mob elements > 100");
|
||||
|
||||
int named = 0, maxlvl = 0;
|
||||
for (uint32_t i = 0; i < p.elements; ++i) {
|
||||
MobRecord m = parse_mob(p.record(i), p.stride);
|
||||
if (!m.name.empty()) {
|
||||
++named;
|
||||
}
|
||||
if (m.level > maxlvl) {
|
||||
maxlvl = m.level;
|
||||
}
|
||||
}
|
||||
CHECK(named > p.elements * 0.8, "mob: >80% named");
|
||||
CHECK(maxlvl > 20 && maxlvl < 256, "mob levels in a sane range");
|
||||
MobRecord first = parse_mob(p.record(0), p.stride);
|
||||
std::printf("mob_proto: %u mobs, stride %u, %d named, maxlvl=%d\n [0] vnum=%u '%s' "
|
||||
"type=%u rank=%u level=%u\n",
|
||||
p.elements, p.stride, named, maxlvl, first.vnum, first.name.c_str(), first.type,
|
||||
first.rank, first.level);
|
||||
CHECK(first.vnum > 0 && first.vnum < 60000, "mob[0] vnum sane");
|
||||
CHECK(!first.name.empty(), "mob[0] has a name");
|
||||
}
|
||||
}
|
||||
|
||||
if (g_fail) {
|
||||
std::fprintf(stderr, "%d check(s) failed\n", g_fail);
|
||||
return 1;
|
||||
}
|
||||
std::printf("all checks passed\n");
|
||||
return 0;
|
||||
}
|
||||
Reference in New Issue
Block a user