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
+112
View File
@@ -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
+140
View File
@@ -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;
}
+143
View File
@@ -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;
}
+150
View File
@@ -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;
}